Back to blog
Backend Systemsintermediate

Content Negotiation — Serve JSON, XML, or CSV From the Same Endpoint

How HTTP content negotiation works in ASP.NET Core — Accept headers, output formatters, custom media types, and how to add XML or CSV support without duplicating endpoints.

LearnixoApril 15, 20265 min read
.NETC#RESTASP.NET CoreContent NegotiationMedia Types
Share:𝕏

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:

HTTP
GET /api/orders/42
Accept: application/json; q=1.0, application/xml; q=0.8, */*; q=0.1

The 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.

C#
// 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

Bash
dotnet add package Microsoft.AspNetCore.Mvc.Formatters.Xml
C#
builder.Services.AddControllers()
    .AddXmlSerializerFormatters();  // adds XmlSerializerOutputFormatter

Now a client that sends Accept: application/xml gets XML:

HTTP
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:

C#
[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:

C#
builder.Services.AddControllers(options =>
{
    options.RespectBrowserAcceptHeader = true;   // honour the Accept header
    options.ReturnHttpNotAcceptable = true;      // return 406 if no match
});

With ReturnHttpNotAcceptable = true:

HTTP
GET /api/orders/42
Accept: text/html

HTTP/1.1 406 Not Acceptable

Custom CSV Output Formatter

Need a CSV download from a list endpoint? Add a custom OutputFormatter:

C#
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:

C#
builder.Services.AddControllers(options =>
{
    options.OutputFormatters.Add(new CsvOutputFormatter());
});

Now a client can request CSV:

HTTP
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,Processing

Content Negotiation in Minimal API

Minimal API doesn't use the formatter pipeline by default. You control the response format explicitly:

C#
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:

C#
builder.Services.AddControllers()
    .AddXmlSerializerFormatters();  // adds both input and output XML formatters
HTTP
POST /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:

HTTP
GET /api/orders/42
Accept: application/vnd.learnixo.order.v2+json
C#
builder.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:

  1. Exact type match (e.g., application/json)
  2. Type with highest quality factor (q value)
  3. Wildcard match (application/*, then */*)
  4. First registered formatter if all else is equal

Register more specific formatters last — ASP.NET Core searches in reverse registration order:

C#
options.OutputFormatters.Add(new CsvOutputFormatter());   // checked last
options.OutputFormatters.Insert(0, new CsvOutputFormatter()); // checked first

Quick 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

Enjoyed this article?

Explore the Backend Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.