REST API Engineering · Lesson 10 of 19
Content Negotiation — Serve JSON, XML or CSV from One Endpoint
Content negotiation is how a client and server agree on the format of the response. The client says what it can accept; the server picks the best match. A well-designed REST API serves the same data in multiple formats from the same URL — no /orders/json vs /orders/xml nonsense.
How It Works
The client sends an Accept header listing the media types it understands, with optional quality values (q) to indicate preference:
GET /api/orders/42
Accept: application/json; q=1.0, application/xml; q=0.8, */*; q=0.1The server looks at this list, compares it to what it can produce, and returns the best match. If there's no match, it returns 406 Not Acceptable.
ASP.NET Core Defaults
By default, ASP.NET Core controllers respond with JSON only. Content negotiation is enabled automatically for [ApiController] controllers but is not configured for Minimal API by default.
// Program.cs — controllers (content negotiation on by default)
builder.Services.AddControllers();
// Check what's registered:
// builder.Services.AddControllers(options =>
// {
// foreach (var f in options.OutputFormatters)
// Console.WriteLine(f.GetType().Name);
// });
// → SystemTextJsonOutputFormatter (the only default)Adding XML Support
dotnet add package Microsoft.AspNetCore.Mvc.Formatters.Xmlbuilder.Services.AddControllers()
.AddXmlSerializerFormatters(); // adds XmlSerializerOutputFormatterNow a client that sends Accept: application/xml gets XML:
GET /api/orders/42
Accept: application/xml
HTTP/1.1 200 OK
Content-Type: application/xml; charset=utf-8
<OrderDto>
<Id>42</Id>
<CustomerName>Acme Corp</CustomerName>
<Total>299.99</Total>
</OrderDto>The same endpoint, same controller action — no code changes:
[HttpGet("{id}")]
public async Task<ActionResult<OrderDto>> GetById(int id, CancellationToken ct)
{
var order = await _service.GetAsync(id, ct);
if (order is null) return NotFound();
return Ok(order); // framework negotiates the format
}Respecting (and Ignoring) the Accept Header
By default, ASP.NET Core returns JSON even if the client asks for an unsupported format (it falls back rather than returning 406). To enforce strict negotiation:
builder.Services.AddControllers(options =>
{
options.RespectBrowserAcceptHeader = true; // honour the Accept header
options.ReturnHttpNotAcceptable = true; // return 406 if no match
});With ReturnHttpNotAcceptable = true:
GET /api/orders/42
Accept: text/html
HTTP/1.1 406 Not AcceptableCustom CSV Output Formatter
Need a CSV download from a list endpoint? Add a custom OutputFormatter:
public class CsvOutputFormatter : TextOutputFormatter
{
public CsvOutputFormatter()
{
SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse("text/csv"));
SupportedEncodings.Add(Encoding.UTF8);
}
protected override bool CanWriteType(Type? type)
=> type is not null && (typeof(IEnumerable).IsAssignableFrom(type));
public override async Task WriteResponseBodyAsync(
OutputFormatterWriteContext context, Encoding selectedEncoding)
{
var response = context.HttpContext.Response;
var items = context.Object as IEnumerable ?? Enumerable.Empty<object>();
var sb = new StringBuilder();
var first = true;
foreach (var item in items)
{
var props = item.GetType().GetProperties();
if (first)
{
// Header row
sb.AppendLine(string.Join(",", props.Select(p => p.Name)));
first = false;
}
// Data row — wrap values containing commas in quotes
var values = props.Select(p =>
{
var val = p.GetValue(item)?.ToString() ?? string.Empty;
return val.Contains(',') ? $"\"{val}\"" : val;
});
sb.AppendLine(string.Join(",", values));
}
await response.WriteAsync(sb.ToString(), selectedEncoding);
}
}Register it:
builder.Services.AddControllers(options =>
{
options.OutputFormatters.Add(new CsvOutputFormatter());
});Now a client can request CSV:
GET /api/orders?from=2026-01-01
Accept: text/csv
HTTP/1.1 200 OK
Content-Type: text/csv; charset=utf-8
Id,CustomerName,Total,Status
42,Acme Corp,299.99,Shipped
43,Beta Ltd,155.00,ProcessingContent Negotiation in Minimal API
Minimal API doesn't use the formatter pipeline by default. You control the response format explicitly:
app.MapGet("/api/orders/{id}", async (int id, HttpContext ctx, IOrderService svc) =>
{
var order = await svc.GetAsync(id);
if (order is null) return Results.NotFound();
// Check Accept header manually
var accept = ctx.Request.Headers.Accept.ToString();
if (accept.Contains("application/xml"))
{
// Return XML (requires serialization)
var xml = SerializeToXml(order);
return Results.Content(xml, "application/xml");
}
if (accept.Contains("text/csv"))
{
var csv = $"Id,Customer,Total\n{order.Id},{order.CustomerName},{order.Total}";
return Results.Content(csv, "text/csv");
}
return Results.Ok(order); // default JSON
});For serious content negotiation in Minimal API, use controllers or a dedicated negotiation library — the manual approach above doesn't scale.
Input Content Negotiation (Request Body)
Content negotiation also applies to the request body via the Content-Type header. By default, ASP.NET Core parses application/json. To accept XML bodies:
builder.Services.AddControllers()
.AddXmlSerializerFormatters(); // adds both input and output XML formattersPOST /api/orders
Content-Type: application/xml
<CreateOrderDto>
<CustomerId>5</CustomerId>
<Items>
<OrderItem><ProductId>12</ProductId><Quantity>2</Quantity></OrderItem>
</Items>
</CreateOrderDto>The [ApiController] attribute handles binding automatically. The action method is identical regardless of input format.
Custom Media Types for Versioning
Custom media types let you version via the Accept header instead of the URL:
GET /api/orders/42
Accept: application/vnd.learnixo.order.v2+jsonbuilder.Services.AddControllers(options =>
{
options.OutputFormatters
.OfType<SystemTextJsonOutputFormatter>()
.First()
.SupportedMediaTypes
.Add("application/vnd.learnixo.order.v2+json");
});This is a valid versioning strategy (RFC 6906) but adds complexity — URL versioning is simpler to discover and debug. Use custom media types only if you have clients that specifically require them (e.g., strict RFC compliance).
Formatter Selection Order
When a client sends multiple acceptable types, ASP.NET Core picks in this order:
- Exact type match (e.g.,
application/json) - Type with highest quality factor (
qvalue) - Wildcard match (
application/*, then*/*) - First registered formatter if all else is equal
Register more specific formatters last — ASP.NET Core searches in reverse registration order:
options.OutputFormatters.Add(new CsvOutputFormatter()); // checked last
options.OutputFormatters.Insert(0, new CsvOutputFormatter()); // checked firstQuick Reference
Default: JSON only
Add XML: .AddXmlSerializerFormatters()
Add CSV: custom OutputFormatter + SupportedMediaTypes
Enforce 406: options.ReturnHttpNotAcceptable = true
Respect browser: options.RespectBrowserAcceptHeader = true
Client header: Accept: application/json, application/xml; q=0.8
Request body: Content-Type: application/json
Custom media type: application/vnd.company.resource.v2+json