Content Negotiation — JSON, XML and Custom Formatters

Content negotiation allows clients to request different response formats using the Accept header. The server inspects the header and serialises the response using the appropriate formatter. While JSON is the dominant format for modern REST APIs, some enterprise clients need XML, some need CSV export, and some browsers send Accept: */* which should still return JSON. ASP.NET Core’s content negotiation pipeline handles this automatically once formatters are configured.

Configuring Formatters

// ── Program.cs — configure content negotiation ────────────────────────────
builder.Services
    .AddControllers(options =>
    {
        // Return 406 Not Acceptable when no formatter can handle the Accept header
        options.ReturnHttpNotAcceptable = true;

        // Respect client's Accept header preference order
        options.RespectBrowserAcceptHeader = true;
    })
    .AddJsonOptions(opts =>
    {
        opts.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
        opts.JsonSerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
    })
    .AddXmlSerializerFormatters();  // adds application/xml support

// ── How Angular's HttpClient sends Accept headers ─────────────────────────
// Angular's default HttpClient does NOT set Accept header on GET requests
// → ASP.NET Core defaults to JSON (first registered formatter)

// Angular HttpClient with explicit JSON Accept:
// this.http.get('/api/posts', { headers: { Accept: 'application/json' } })

// Angular HttpClient requesting XML:
// this.http.get('/api/posts', { headers: { Accept: 'application/xml' }, responseType: 'text' })
Note: When ReturnHttpNotAcceptable = true, an Accept: text/csv request returns 406 if no CSV formatter is registered. Without this setting, the server falls back to the default formatter (JSON) regardless of what the client requested. Returning 406 is more correct REST behaviour — the client explicitly said “I want CSV” and the server should not silently return JSON instead. For public APIs, 406 helps client developers catch Accept header mismatches early in development.
Tip: For CSV export functionality, add a custom output formatter rather than a dedicated controller action that returns a CSV file. A custom formatter is invoked automatically based on the Accept: text/csv header, keeping the controller action clean. The formatter implements TextOutputFormatter, overrides CanWriteType() and WriteResponseBodyAsync(). The controller action remains: return Ok(data) — the formatter handles the CSV conversion when the Accept header requests it.
Warning: XML serialisation with AddXmlSerializerFormatters() uses XmlSerializer which requires types to be XML-serialisable. C# records with primary constructors, anonymous types, and types with readonly properties may not serialise correctly with XmlSerializer. If your API must support XML, design your DTOs as classes with parameterless constructors and public properties, or use AddXmlDataContractSerializerFormatters() which supports more type scenarios at the cost of slightly different output format.

Custom CSV Output Formatter

// ── Custom CSV formatter ───────────────────────────────────────────────────
public class CsvOutputFormatter : TextOutputFormatter
{
    public CsvOutputFormatter()
    {
        SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse("text/csv"));
        SupportedEncodings.Add(Encoding.UTF8);
        SupportedEncodings.Add(Encoding.Unicode);
    }

    protected override bool CanWriteType(Type? type)
        => type is not null && typeof(IEnumerable<PostSummaryDto>).IsAssignableFrom(type);

    public override async Task WriteResponseBodyAsync(
        OutputFormatterWriteContext context, Encoding selectedEncoding)
    {
        var posts = context.Object as IEnumerable<PostSummaryDto>;
        if (posts is null) return;

        var response = context.HttpContext.Response;
        await using var writer = new StreamWriter(response.Body, selectedEncoding, leaveOpen: true);

        // Header row
        await writer.WriteLineAsync("Id,Title,Author,PublishedAt,ViewCount");

        // Data rows
        foreach (var post in posts)
            await writer.WriteLineAsync(
                $"{post.Id},{EscapeCsv(post.Title)},{EscapeCsv(post.AuthorName)}," +
                $"{post.PublishedAt:yyyy-MM-dd},{post.ViewCount}");
    }

    private static string EscapeCsv(string value)
        => value.Contains(',') || value.Contains('"')
            ? $"\"{value.Replace("\"", "\"\"")}\""
            : value;
}

// ── Register ──────────────────────────────────────────────────────────────
builder.Services.AddControllers(opts =>
{
    opts.OutputFormatters.Add(new CsvOutputFormatter());
});

Common Mistakes

Mistake 1 — Not setting ReturnHttpNotAcceptable (silent format mismatch)

❌ Wrong — client requests CSV, server silently returns JSON; client crashes trying to parse JSON as CSV.

✅ Correct — set options.ReturnHttpNotAcceptable = true to return 406 for unsupported formats.

Mistake 2 — Using XmlSerializer with records (serialisation fails at runtime)

❌ Wrong — record types without parameterless constructors fail XmlSerializer at runtime.

✅ Correct — use AddXmlDataContractSerializerFormatters() for better type compatibility.

🧠 Test Yourself

An Angular client sends GET /api/posts with no Accept header. ReturnHttpNotAcceptable is true. What format does the response use?