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:

  1. It reads the Accept header from the request.
  2. It iterates through registered output formatters.
  3. The first formatter that can write the response in a requested media type wins.
  4. 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:

Example.cs
builder.Services.AddControllers()
    .AddXmlSerializerFormatters();

Now requests with Accept: application/xml receive XML responses:

Example.cs
[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:

Example.cs
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:

Example.cs
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:

CsvOutputFormatter.cs
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:

Example.cs
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:

ProductsController.cs
[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:

Example.cs
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:

Example.cs
[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 a fundamental part of HTTP that's often overlooked. Getting it right means your API works cleanly with diverse clients without version-specific workarounds.