Content Negotiation in REST APIs 2026
Content Negotiation in REST APIs 2026
Content negotiation lets clients and servers agree on the response format — JSON, XML, CSV, or custom media types. The client says what it wants via the Accept header, the server returns the best match. In practice, most APIs only support JSON. But understanding content negotiation unlocks API versioning, format flexibility, and proper HTTP semantics.
TL;DR
- Most APIs should support JSON only — content negotiation adds complexity that rarely pays off unless you have specific multi-format needs
- Pagination links and data exports (CSV) are the two cases where multiple formats deliver clear developer value
- Always include the
Vary: Acceptheader when supporting multiple formats — CDNs will serve wrong cached responses without it - Versioning via
Acceptheader (content negotiation) is technically cleaner than URL versioning but practically worse — harder to test, less visible, trickier to route - Use quality values correctly:
Accept: application/json;q=1.0, text/csv;q=0.5— your server must parse and honor these
How It Works
Request
GET /api/users/123
Accept: application/json
Response
HTTP/1.1 200 OK
Content-Type: application/json
{"id": 123, "name": "John"}
If the server can't produce the requested format, it returns 406 Not Acceptable.
The Accept Header
Clients specify preferred formats with quality values (0-1):
Accept: application/json, application/xml;q=0.9, text/csv;q=0.5
This means: prefer JSON, XML is acceptable, CSV is last resort. Quality defaults to 1.0 if not specified.
Common Media Types
| Media Type | Use Case |
|---|---|
application/json | Default for APIs |
application/xml | Legacy enterprise APIs |
text/csv | Data export, spreadsheets |
application/pdf | Document generation |
text/html | Browser-readable responses |
application/octet-stream | Binary file download |
multipart/form-data | File uploads |
text/event-stream | Server-Sent Events |
Custom Media Types
Custom media types encode API-specific information:
Accept: application/vnd.yourapi.user.v2+json
Format: application/vnd.{vendor}.{resource}.{version}+{format}
GitHub's Approach
Accept: application/vnd.github.v3+json
Accept: application/vnd.github.v3.raw # Raw file content
Accept: application/vnd.github.v3.html # HTML rendered content
Accept: application/vnd.github.v3.diff # Diff format
Accept: application/vnd.github.v3.patch # Patch format
GitHub uses custom media types to control both the API version and the response format for the same resource.
Content Negotiation for Versioning
Instead of URL path versioning (/v1/users), version via the Accept header:
# Version 1
Accept: application/vnd.yourapi.v1+json
# Version 2
Accept: application/vnd.yourapi.v2+json
Pros: Clean URLs, per-resource versioning, RESTful. Cons: Harder to test (can't paste in browser), less visible, more complex routing.
Implementation Patterns
1. Default Format
Always have a default. If no Accept header is provided, return JSON:
GET /api/users → application/json (default)
2. Format via Extension (Pragmatic)
Some APIs support format via URL extension as a fallback:
GET /api/users.json
GET /api/users.csv
GET /api/users.xml
This isn't proper content negotiation, but it's practical and easy to test.
3. Format via Query Parameter
GET /api/users?format=csv
Also not proper content negotiation, but commonly used alongside Accept header support.
4. Response Format Matching
Your server should:
- Parse the
Acceptheader - Sort by quality value
- Find the first format you support
- Return 406 if no match
Practical Recommendations
For Most APIs
- Support
application/jsonas the only format - Return
Content-Type: application/jsonon all responses - Ignore the
Acceptheader (always return JSON) - Use URL path versioning instead of content negotiation for versions
This is what 95% of APIs do, and it's fine.
For APIs Needing Multiple Formats
- Support
application/json(default) and one or two alternatives (CSV, XML) - Respect the
Acceptheader - Return
406 Not Acceptablefor unsupported formats - Include
Vary: Acceptheader for proper caching
For Enterprise APIs
- Full content negotiation with custom media types
- Version via
Acceptheader - Support JSON, XML, and potentially CSV/PDF
- Document supported media types in OpenAPI spec
Common Mistakes
| Mistake | Impact | Fix |
|---|---|---|
| Ignoring Accept header but returning wrong type | Client parse errors | Respect Accept or always return JSON |
| No 406 response | Client gets unexpected format | Return 406 for unsupported formats |
| Missing Content-Type header | Client can't parse response | Always include Content-Type |
| No Vary: Accept header | CDN caches wrong format | Add Vary: Accept when supporting multiple formats |
| Over-engineering formats | Maintenance burden | Start with JSON only, add formats when needed |
Implementing Content Negotiation in Node.js
Building a content negotiation handler correctly requires parsing quality values and matching against your supported types. Here's a complete implementation in Express and Hono:
Express Middleware
import { Request, Response, NextFunction } from 'express';
const SUPPORTED_TYPES = ['application/json', 'text/csv', 'application/xml'];
function parseAcceptHeader(accept: string): { type: string; quality: number }[] {
return accept
.split(',')
.map((part) => {
const [type, ...params] = part.trim().split(';');
const qParam = params.find((p) => p.trim().startsWith('q='));
const quality = qParam ? parseFloat(qParam.split('=')[1]) : 1.0;
return { type: type.trim(), quality };
})
.sort((a, b) => b.quality - a.quality); // Descending quality
}
function negotiateContentType(accept: string | undefined): string | null {
if (!accept) return 'application/json'; // Default
const preferences = parseAcceptHeader(accept);
for (const { type } of preferences) {
if (type === '*/*') return 'application/json'; // Wildcard → default
if (SUPPORTED_TYPES.includes(type)) return type;
}
return null; // No match → 406
}
export function contentNegotiation(req: Request, res: Response, next: NextFunction) {
const accept = req.headers.accept;
const contentType = negotiateContentType(accept);
if (!contentType) {
return res.status(406).json({
error: 'not_acceptable',
message: `Supported types: ${SUPPORTED_TYPES.join(', ')}`,
});
}
res.locals.responseContentType = contentType;
next();
}
// Route using the negotiated type
app.get('/api/users', contentNegotiation, async (req, res) => {
const users = await db.users.findAll();
const contentType = res.locals.responseContentType;
res.setHeader('Content-Type', contentType);
res.setHeader('Vary', 'Accept');
if (contentType === 'text/csv') {
return res.send(usersToCSV(users));
} else if (contentType === 'application/xml') {
return res.send(usersToXML(users));
} else {
return res.json(users);
}
});
Hono Handler
Hono has built-in content type utilities, but implementing negotiation manually gives more control:
import { Hono } from 'hono';
const app = new Hono();
app.get('/api/users', async (c) => {
const accept = c.req.header('Accept') ?? 'application/json';
const users = await db.users.findAll();
if (accept.includes('text/csv')) {
c.header('Content-Type', 'text/csv');
c.header('Vary', 'Accept');
c.header('Content-Disposition', 'attachment; filename="users.csv"');
return c.body(usersToCSV(users));
}
if (accept.includes('application/xml')) {
c.header('Content-Type', 'application/xml');
c.header('Vary', 'Accept');
return c.body(usersToXML(users));
}
// Default: JSON
c.header('Vary', 'Accept');
return c.json(users);
});
One gotcha with quality value parsing: Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8 is what browsers send. If you support */*, always return your default format (JSON), not the first format in your supported list — otherwise browsers navigating to your API endpoint get CSV downloads instead of JSON.
CSV and XML Support
CSV for Data Exports
CSV is the format developers actually ask for when they say "export to spreadsheet." Finance teams, data analysts, and anyone using Excel will request CSV. It's the right format for bulk data exports, not for API responses in normal operation.
Key considerations for proper CSV output:
RFC 4180 compliance. Fields containing commas or newlines must be quoted. Quotes within fields must be escaped with a double-quote:
function escapeCSVField(value: string | number | boolean | null): string {
if (value === null || value === undefined) return '';
const str = String(value);
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
return `"${str.replace(/"/g, '""')}"`;
}
return str;
}
function usersToCSV(users: User[]): string {
const headers = ['id', 'name', 'email', 'created_at'];
const rows = users.map((u) =>
headers.map((h) => escapeCSVField(u[h as keyof User])).join(',')
);
return [headers.join(','), ...rows].join('\r\n'); // CRLF per RFC 4180
}
Excel encoding. Excel on Windows expects UTF-8 with a BOM (byte order mark) for non-ASCII characters to render correctly. Add \uFEFF at the start of the file when the data might contain non-ASCII content.
Content-Disposition header. Always include Content-Disposition: attachment; filename="export.csv" for CSV responses. Without it, some browsers try to render the CSV as text in the browser tab.
XML for Enterprise and Legacy
XML is rarely the primary format for new APIs, but legacy enterprise integrations — ERP systems, SOAP-era middleware, EDI workflows — often require it. If you serve enterprise customers, XML support is a contractual checkbox.
For well-formed XML from TypeScript, use a serialization library rather than string concatenation:
import { create } from 'xmlbuilder2';
function usersToXML(users: User[]): string {
const root = create({ version: '1.0' }).ele('users');
for (const user of users) {
root.ele('user')
.ele('id').txt(user.id).up()
.ele('name').txt(user.name).up()
.ele('email').txt(user.email).up()
.up();
}
return root.end({ prettyPrint: true });
}
XML serialization libraries handle character escaping for you. Manual string concatenation will break on names containing &, <, or >.
Versioning Strategies Compared
The three common approaches to REST API versioning, with honest tradeoffs:
| Strategy | Example | Pros | Cons |
|---|---|---|---|
| URL path | /v2/users | Simple, debuggable, browser-testable | "Unclean" URLs, versioning bleeds into URLs |
| Accept header | Accept: vnd.api.v2+json | Clean URLs, per-resource versioning | Hard to test, harder to route, less visible |
| Query parameter | /users?version=2 | Simple, visible | Non-standard, pollutes query string |
URL path versioning wins for most public APIs. GitHub, Stripe, Twilio, and almost every major API uses it. The "unclean URLs" argument is academic — developers find /v2/orders more readable than an Accept header they have to remember to set.
Accept header versioning shines in one niche: when you want to version at the resource level rather than the API level. Different resources can evolve at different paces. But this requires significant gateway or router infrastructure to handle correctly.
For a full versioning strategy guide, see how to version REST APIs and API breaking changes without breaking clients.
Streaming and Content Negotiation
Some content types imply a streaming response rather than a buffered one.
Server-Sent Events (text/event-stream):
app.get('/api/events', (req, res) => {
const accept = req.headers.accept ?? '';
if (!accept.includes('text/event-stream')) {
return res.status(406).json({ error: 'SSE requires Accept: text/event-stream' });
}
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
const send = (data: unknown) => {
res.write(`data: ${JSON.stringify(data)}\n\n`);
};
// Push events
const interval = setInterval(() => send({ timestamp: Date.now() }), 1000);
req.on('close', () => clearInterval(interval));
});
NDJSON (Newline-Delimited JSON, application/x-ndjson):
NDJSON is useful for streaming large datasets where the client processes records incrementally rather than waiting for the full response:
Content-Type: application/x-ndjson
{"id": 1, "name": "Alice"}\n
{"id": 2, "name": "Bob"}\n
{"id": 3, "name": "Charlie"}\n
The client parses one JSON object per line as the stream arrives. This is the format used by many LLM APIs for token streaming and by log aggregation systems for bulk ingestion.
For a deeper look at streaming APIs, see building real-time APIs: WebSockets vs SSE.
Caching and Content Negotiation
This is where content negotiation most often goes wrong. CDNs and proxies cache responses by URL. If you serve both JSON and CSV from /api/users based on the Accept header, a CDN might cache the first response (say, JSON) and serve it for all subsequent requests — including those requesting CSV.
The Vary Header
The Vary response header tells caches which request headers affect the response:
Vary: Accept
With this header, the CDN caches separate versions for each unique Accept value. A request with Accept: application/json gets a different cache entry from Accept: text/csv.
The downside: Vary significantly reduces cache hit rates because cache keys become more specific. Multiple formats from one URL means multiple cached versions.
CDN Behavior Differences
Not all CDNs handle Vary correctly:
- Cloudflare respects
Vary: Acceptbut may normalize certain Accept values - CloudFront supports
Varybut requires explicit configuration — it ignores headers not in the "Allowed Headers" list - Fastly has good
Varysupport but charges per cached variant - Vercel Edge Network respects
Varyfor Edge Functions
If you're serving multiple formats and using a CDN, test the cache behavior explicitly. A mismatch between what you expect and what the CDN caches can serve JSON to clients requesting CSV for weeks without anyone noticing.
Cache Invalidation Edge Cases
When you update data, you need to invalidate all cached variants. Invalidating /api/users without considering Vary variants may leave stale CSV or XML responses cached even after the JSON version is invalidated.
The pragmatic solution for high-traffic APIs: serve different formats from different paths. /api/users for JSON, /api/exports/users.csv for CSV. Clean URLs, no Vary complexity, cache invalidation by path. The "format in path" approach breaks content negotiation purity but solves real operational problems.
Conclusion
Content negotiation is one of HTTP's more elegant features — letting clients and servers communicate format preferences without hardcoding them. In practice, most APIs implement it partially or not at all, and that's usually fine. The Vary: Accept caching requirement and the complexity of parsing quality values make full content negotiation worth the overhead only when you genuinely need multiple formats.
Start with JSON only. Add CSV when users ask for data exports. Add XML only for enterprise integrations that require it. Use URL versioning instead of Accept-header versioning for the 95% of cases where simplicity matters more than URL aesthetics.