Content Negotiation in ASP.NET Core
Content negotiation is the process by which a server selects the best response format based on what the client can accept. When a client sends Accept: application/xml, the server should return XML — not JSON. ASP.NET Core handles this through output formatters, and you can extend the system with custom formatters for any media type.
How It Works
When a controller action returns Ok(data) or any ObjectResult, the framework runs content negotiation:
- It reads the
Acceptheader from the request. - It iterates through registered output formatters.
- The first formatter that can write the response in a requested media type wins.
- If no formatter matches, the default (JSON) is used — unless you've configured
ReturnHttpNotAcceptable.
Default Behaviour
Out of the box, ASP.NET Core only includes the JSON formatter (using System.Text.Json). To enable XML:
builder.Services.AddControllers()
.AddXmlSerializerFormatters();
Now requests with Accept: application/xml receive XML responses:
[ApiController]
[Route("api/products")]
public class ProductsController : ControllerBase
{
[HttpGet("{id}")]
public IActionResult GetById(int id)
{
var product = new Product { Id = id, Name = "Widget", Price = 9.99m };
return Ok(product); // Format depends on Accept header
}
}
Enforcing Strict Negotiation
By default, if the client requests a format you don't support, the server falls back to JSON. To return 406 Not Acceptable instead:
builder.Services.AddControllers(options =>
{
options.ReturnHttpNotAcceptable = true;
});
This is good practice for APIs where clients must explicitly opt into a format.
Respecting Content-Type for Input
Input formatters handle deserialisation of request bodies. The Content-Type header tells the server which formatter to use:
builder.Services.AddControllers()
.AddXmlSerializerFormatters(); // Enables both XML input and output
A POST with Content-Type: application/xml will now be deserialised from XML automatically.
Building a Custom Output Formatter
For formats beyond JSON and XML, write a custom TextOutputFormatter. Here's a CSV formatter:
public class CsvOutputFormatter : TextOutputFormatter
{
public CsvOutputFormatter()
{
SupportedMediaTypes.Add("text/csv");
SupportedEncodings.Add(Encoding.UTF8);
}
protected override bool CanWriteType(Type? type)
{
if (type is null) return false;
// Support IEnumerable<T> types
return typeof(IEnumerable).IsAssignableFrom(type) && type != typeof(string);
}
public override async Task WriteResponseBodyAsync(OutputFormatterWriteContext context, Encoding encoding)
{
var response = context.HttpContext.Response;
var items = (IEnumerable)context.Object!;
var sb = new StringBuilder();
var first = true;
foreach (var item in items)
{
var type = item.GetType();
var properties = type.GetProperties();
if (first)
{
sb.AppendLine(string.Join(",", properties.Select(p => p.Name)));
first = false;
}
sb.AppendLine(string.Join(",",
properties.Select(p => EscapeCsvField(p.GetValue(item)?.ToString() ?? ""))));
}
await response.WriteAsync(sb.ToString(), encoding);
}
private static string EscapeCsvField(string field)
{
if (field.Contains(',') || field.Contains('"') || field.Contains('\n'))
{
return $"\"{field.Replace("\"", "\"\"")}\"";
}
return field;
}
}
Register it:
builder.Services.AddControllers(options =>
{
options.OutputFormatters.Add(new CsvOutputFormatter());
});
Now Accept: text/csv returns comma-separated values.
Format Filters and URL-Based Negotiation
Sometimes you want the format in the URL rather than a header. The FormatFilter attribute enables this:
[ApiController]
[Route("api/products")]
public class ProductsController : ControllerBase
{
[HttpGet("{id}.{format?}")]
[FormatFilter]
public IActionResult GetById(int id)
{
var product = new Product { Id = id, Name = "Widget", Price = 9.99m };
return Ok(product);
}
}
Map format extensions to media types:
builder.Services.AddControllers(options =>
{
options.FormatterMappings.SetMediaTypeMappingForFormat("csv", "text/csv");
options.FormatterMappings.SetMediaTypeMappingForFormat("xml", "application/xml");
});
Now /api/products/1.csv returns CSV and /api/products/1.xml returns XML.
Produces and Consumes Attributes
Document supported media types explicitly:
[HttpGet("{id}")]
[Produces("application/json", "application/xml", "text/csv")]
public IActionResult GetById(int id)
{
// ...
}
This feeds into OpenAPI generation and restricts which formatters the endpoint will use.
Key Points
- Content negotiation is driven by the
Acceptheader and output formatters. - JSON is the only default — add XML or custom formatters explicitly.
- Use
ReturnHttpNotAcceptable = truefor strict APIs. - Custom formatters are straightforward to write — inherit from
TextOutputFormatterorOutputFormatter. - Use
[Produces]to document and constrain your endpoints.
Content negotiation is a fundamental part of HTTP that's often overlooked. Getting it right means your API works cleanly with diverse clients without version-specific workarounds.