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' })
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.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.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.