<!-- APIScout AI-readable guide source -->
<!-- Canonical: https://apiscout.dev/guides/content-negotiation-rest-apis-guide-2026 -->
<!-- Raw Markdown: https://apiscout.dev/guides/content-negotiation-rest-apis-guide-2026/raw.md -->
<!-- Source path: content/guides/content-negotiation-rest-apis-guide-2026.mdx -->

---
og_image: "/images/guides/content-negotiation-rest-apis-guide-2026.webp"
title: "Content Negotiation in REST APIs 2026"
description: "How content negotiation works in REST APIs — Accept headers, media types, versioning via content type, and practical implementation patterns for 2026."
date: "2026-03-08"
author: "APIScout Team"
tags: ["content-negotiation", "rest-api", "media-types", "api-design", "best-practices"]
tier: 1
---

# 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: Accept` header when supporting multiple formats — CDNs will serve wrong cached responses without it
- Versioning via `Accept` header (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:

1. Parse the `Accept` header
2. Sort by quality value
3. Find the first format you support
4. Return 406 if no match

## Practical Recommendations

### For Most APIs

1. Support `application/json` as the only format
2. Return `Content-Type: application/json` on all responses
3. Ignore the `Accept` header (always return JSON)
4. 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

1. Support `application/json` (default) and one or two alternatives (CSV, XML)
2. Respect the `Accept` header
3. Return `406 Not Acceptable` for unsupported formats
4. Include `Vary: Accept` header for proper caching

### For Enterprise APIs

1. Full content negotiation with custom media types
2. Version via `Accept` header
3. Support JSON, XML, and potentially CSV/PDF
4. 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

```typescript
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:

```typescript
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:

```typescript
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:

```typescript
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](/blog/how-to-version-rest-apis-2026) and [API breaking changes without breaking clients](/blog/api-breaking-changes-without-breaking-clients-2026).

## Streaming and Content Negotiation

Some content types imply a streaming response rather than a buffered one.

**Server-Sent Events (`text/event-stream`):**

```typescript
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](/blog/building-real-time-apis-websockets-vs-sse-2026).

## 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: Accept` but may normalize certain Accept values
- **CloudFront** supports `Vary` but requires explicit configuration — it ignores headers not in the "Allowed Headers" list
- **Fastly** has good `Vary` support but charges per cached variant
- **Vercel Edge Network** respects `Vary` for 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.
