# Content Negotiation: Accept, Languages and Caches

> Implement HTTP content negotiation with Accept headers, q-values, Vary and 406 responses, with Express, Django and nginx examples and language SEO guidance.

Source: https://howhttpworks.com/guides/content-negotiation
Last reviewed: 2026-10-05

> **TL;DR:** Content negotiation lets one URL return different versions of a resource, such as JSON or HTML, French or English, gzip or uncompressed. The client states preferences in `Accept`, `Accept-Language` and `Accept-Encoding`. The server picks from the variants it actually has, labels the result with `Content-Type`, `Content-Language` or `Content-Encoding`, and lists the request headers it used in `Vary`. Decide up front whether a mismatch gets a default or `406`, check that your CDN keys on those headers, and give translations you want indexed their own URLs.

## One resource, several representations

The [glossary entry](https://howhttpworks.com/glossary/content-negotiation) has the short definition. The hard part in practice is picking a response without surprising the caller or a cache along the way. Each header negotiates a different property:

- [`Accept`](https://howhttpworks.com/headers/accept) lists media types such as `application/json` and `text/html`. Label the result with `Content-Type`.
- [`Accept-Language`](https://howhttpworks.com/headers/accept-language) lists language preferences such as `fr-CA, fr;q=0.9, en;q=0.5`. Label the audience language with `Content-Language`.
- [`Accept-Encoding`](https://howhttpworks.com/headers/accept-encoding) lists content codings such as `br` and `gzip`. If you apply one, label it with `Content-Encoding`; an uncompressed response normally leaves that header out.

[MDN calls this server-driven negotiation](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Content_negotiation). The client states what it would like, and the server's selection logic makes the call.

The requests and responses on this page are examples rather than captured traffic. Here's a request for a report:

```http
GET /reports/42 HTTP/1.1
Host: api.example.com
Accept: application/json, text/csv;q=0.7
Accept-Language: fr, en;q=0.5
Accept-Encoding: gzip, identity;q=0.5

```

If the server has a French JSON version and gzip turned on, the response headers might look like this:

```http
HTTP/1.1 200 OK
Date: Mon, 05 Oct 2026 00:00:00 GMT
Content-Type: application/json
Content-Language: fr
Content-Encoding: gzip
Vary: Accept, Accept-Language, Accept-Encoding
Cache-Control: public, max-age=60
Transfer-Encoding: chunked

```

The chunked, compressed body is left out. Only mark a response `public` if every caller is allowed to see the same report.

## Quality values: score candidates, not header positions

[Quality values](https://developer.mozilla.org/en-US/docs/Glossary/Quality_values) range from `0` to `1`, with up to three decimal places. A missing `q` means `1`, and `q=0` rules a choice out completely ([RFC 9110 §12.4.2](https://www.rfc-editor.org/rfc/rfc9110#section-12.4.2)). `q=0.8` expresses preference; it says nothing about compression quality.

For `Accept`, a candidate's quality comes from the most specific media range that matches it. Take this header:

```http
Accept: text/*;q=0.9, text/html;q=0.2, application/json;q=0.8
```

HTML scores `0.2`, JSON `0.8`, and plain text `0.9`. If you only offer HTML and JSON, JSON wins. HTML also matches `text/*`, but the more specific `text/html` range sets its score, so it stays at `0.2`. That precedence rule is in [RFC 9110 §12.5.1](https://www.rfc-editor.org/rfc/rfc9110#section-12.5.1).

So the algorithm is: list the variants you offer, work out each one's quality, drop anything at zero, then break ties with a documented rule. Only consider variants you can actually produce; a type showing up in the request doesn't oblige you to invent it. The framework examples below hand the parsing to built-in negotiation helpers.

Languages need their own matching rules, because you have to decide how a regional tag maps to the translations you have. A request for `fr-CA` tells you what the user wants, not that you have Canadian French. If the user picked a language explicitly, through the URL or a setting, let that beat the header, as [MDN recommends](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Accept-Language).

Encoding has a special fallback too. `identity` means no coding, and it's acceptable unless the client explicitly excludes it. A client that accepts gzip isn't demanding it, so you're free to send some responses uncompressed. A missing `Accept-Encoding` header means any coding is fine, while an empty one asks for no coding ([RFC 9110 §12.5.3](https://www.rfc-editor.org/rfc/rfc9110#section-12.5.3)). For configuration, see [MDN's encoding reference](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Accept-Encoding) and [HTTP compression](https://howhttpworks.com/guides/http-compression).

## 406 or a default: make it an endpoint policy

[RFC 9110 §12.4.1](https://www.rfc-editor.org/rfc/rfc9110#section-12.4.1) lets a server either ignore a negotiation header it can't satisfy and send a default, or honor it with [`406 Not Acceptable`](https://howhttpworks.com/status-codes/406). The spec leaves the choice to you, so make it per endpoint.

For an API that promises JSON and CSV, answer `Accept: application/xml` with `406` and document the types you offer. For a page people read, a default language plus a language switcher usually serves them better, and [MDN notes that language negotiation commonly falls back](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Accept-Language). Either way, label the response with what you actually sent.

Encoding follows its own rule: always send bytes the client can decode. If Brotli is excluded but `identity` is allowed, send the response uncompressed. Keep that decision separate from your media-type fallback.

## Vary separates cache variants, if the CDN supports it

[`Vary`](https://howhttpworks.com/headers/vary) lists the **request** headers that influenced the choice. If you pick by language and media type, send `Vary: Accept, Accept-Language`, and add `Accept-Encoding` when the coding varies. Send the same list on every variant, defaults included, and append to an existing `Vary` rather than overwriting fields another layer added.

Under [RFC 9111 §4.1](https://www.rfc-editor.org/rfc/rfc9111#section-4.1), a cache can't reuse a stored response without revalidating it when the listed request headers don't match. That's what keeps a French response from being served to an English request. `Vary` only controls which stored response matches; whether anything gets stored, and for how long, still follows the normal caching rules. `Vary: *` never matches, so it rules out this kind of reuse entirely.

CDNs add their own limits. [Cloudflare's current documentation](https://developers.cloudflare.com/cache/concepts/cache-control/#other) says it ignores general `Vary` values by default. The exceptions are a configured Cache Rules Vary setting, configured Vary for images, and `Vary: Accept-Encoding`. So `Vary: Accept-Language` or `Vary: Accept` alone may do nothing for your zone; check its configuration.

Before you cache negotiated responses at the edge, confirm the edge's cache key separates every variant the origin can produce. If it can't, use separate URLs or skip shared caching on that route. If you normalize languages to `en` or `fr` in a custom cache key, make the origin choose from the same normalized value. Caching by `en` while the origin still distinguishes `en-US` from `en-GB` will mix them up. [CDN not caching](https://howhttpworks.com/debug/cdn-not-caching) covers the wider cache investigation.

## Real server examples

These examples are code to adapt, not recorded server output. Each negotiates between HTML and JSON and states its mismatch policy explicitly.

### Express: req.accepts()

[`req.accepts()`](https://expressjs.com/en/5x/api/request/#req.accepts) returns the best match from the types you offer, or `false`. [`res.vary()`](https://expressjs.com/en/5x/api/response/#res.vary) adds a field to `Vary` without duplicating it:

```javascript
const express = require('express');
const app = express();

app.get('/report', (req, res) => {
  res.vary('Accept');
  const type = req.accepts(['application/json', 'text/html']);
  if (!type) return res.status(406).end();
  if (type === 'application/json') return res.json({ status: 'ready' });
  return res.type('html').send('<p>Report ready</p>');
});

app.listen(3000);
```

Pass full media types so you can compare the return value directly. Point the curl probes further down at `http://localhost:3000/report`. This route doesn't compress anything; if you add compression middleware, it handles coding negotiation and its own `Vary` entry.

### Django: get_preferred_type(), with Vary on the view

[Django added `get_preferred_type()` in 5.2](https://docs.djangoproject.com/en/6.0/ref/request-response/#django.http.HttpRequest.get_preferred_type). It returns `None` when none of the offered types match. Add `vary_on_headers` so Django's cache knows the response depends on `Accept`:

```python
from django.http import HttpResponse, JsonResponse
from django.views.decorators.vary import vary_on_headers

@vary_on_headers("Accept")
def report(request):
    media_type = request.get_preferred_type([
        "application/json", "text/html",
    ])
    if media_type is None:
        return HttpResponse(status=406)
    if media_type == "application/json":
        return JsonResponse({"status": "ready"})
    return HttpResponse("<p>Report ready</p>", content_type="text/html")
```

When the client sends a wildcard, Django returns the first type in your list. `request.accepts("text/html")` only answers yes or no, so it can't rank HTML against JSON. Use the selection helper whenever you offer both.

### nginx: map is a language hint, not a q-value parser

The [`map` directive](https://nginx.org/en/docs/http/ngx_http_map_module.html#map) goes in the `http` context. This one picks French when the header starts with an unweighted French tag, and English otherwise:

```nginx
map $http_accept_language $welcome_language {
    default en;
    "~*^fr(-[a-z0-9]+)*([[:space:]]*,|[[:space:]]*$)" fr;
}

server {
    listen 8080;
    location = /welcome {
        proxy_pass http://127.0.0.1:3000;
        proxy_set_header X-Selected-Language $welcome_language;
    }
}
```

`X-Selected-Language` is a private contract between nginx and your backend. The backend reads it, renders that language, and sends `Content-Language` plus `Vary: Accept-Language`. Because nginx sets the header itself, any value a client sends in it gets overwritten.

This picks French for `fr` and `fr-CA,en;q=0.5`, but sends `fr;q=0.9,en;q=1` to the English default. It **doesn't** parse quality values or find the best language in an arbitrary list. For real negotiation, pass the header through to application code and use a language negotiation library. Keep this shortcut off pages where every translation has to be discoverable.

## Translations for SEO: separate URLs and hreflang

[Google recommends separate URLs for language versions](https://developers.google.com/search/docs/specialty/international/managing-multi-regional-sites#use-different-urls-for-different-language-versions) instead of changing what one URL shows based on cookies or browser settings. Googlebot doesn't send `Accept-Language`, so if your variants depend only on that header, some of them may never get crawled.

Give each language a stable URL, such as `https://example.com/en/help` and `https://example.com/fr/help`, link them to each other, and mark the alternatives with `hreflang`. Let people stay on the language URL they chose instead of redirecting them automatically. Suggesting a language on an entry page is fine, but it's a separate feature from making translations indexable.

## API versions: media types or URLs

One option is a separate media type per API version. The type below is a made-up application contract, not a registered vendor type:

```http
Accept: application/vnd.example.report.v2+json
```

Your dispatcher matches that exact type, echoes it in `Content-Type`, and adds `Vary: Accept` when the same URL serves other versions too. [The `+json` suffix marks the syntax as JSON](https://www.rfc-editor.org/rfc/rfc6839#section-3.1); it doesn't route versions for you or make two schemas compatible. Decide what a missing or unsupported version gets, including whether an explicitly requested unsupported type returns `406`.

The other option is `/v1/reports/42` and `/v2/reports/42`. Version-specific URLs show up in links and cache keys, which makes them easy to reason about. For a small API I'd pick version URLs unless callers already rely on a media-type contract. Either design needs a documented default, a migration policy, and checks that each response matches the schema that was selected.

## Client Hints: request only what affects the response

[`Accept-CH`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Accept-CH) is a response header that asks supporting clients to send specific hints on later requests. It only works in a secure context. For example, a response can ask for the platform hint:

```http
Accept-CH: Sec-CH-UA-Platform
```

Have a fallback for when the hint doesn't arrive. If the platform hint changes which response you send, add `Sec-CH-UA-Platform` to `Vary` and confirm your edge can key on it. Only vary on hints that actually change the response: every extra dimension multiplies your cache variants.

## Probe every offered variant and the rejection path

Swap in your own endpoint. Run these and read the headers you get back:

```bash
curl -sS -D - -o /dev/null -H 'Accept: application/json' https://api.example.com/report
curl -sS -D - -o /dev/null -H 'Accept: text/html' https://api.example.com/report
curl -sS -D - -o /dev/null -H 'Accept: application/xml' https://api.example.com/report
curl -sS -D - -o /dev/null -H 'Accept: text/*;q=0.9, text/html;q=0.2, application/json;q=0.8' https://api.example.com/report
```

With the strict framework examples above, you should see JSON, HTML, `406`, then JSON. Also try a missing `Accept` (`-H 'Accept:'`), `*/*`, and a type excluded with `q=0`.

For a language endpoint, alternate French and English requests against the **same** public URL. Check the body, not only `Content-Language`, `Vary`, `Age` and the CDN cache status. Then repeat against the origin. If the origin answers correctly but the edge returns the wrong language, the edge's variant key isn't separating them. Seeing `Vary` in the response tells you the origin sent it, not that the edge honors it.

## Related

- [Content negotiation glossary](https://howhttpworks.com/glossary/content-negotiation) for the short definition.
- [Accept](https://howhttpworks.com/headers/accept), [Accept-Language](https://howhttpworks.com/headers/accept-language) and [Accept-Encoding](https://howhttpworks.com/headers/accept-encoding) for field syntax.
- [Vary](https://howhttpworks.com/headers/vary) and [HTTP headers and caching](https://howhttpworks.com/guides/headers-and-caching) for cache behavior.
- [406 Not Acceptable](https://howhttpworks.com/status-codes/406) and [CDN not caching](https://howhttpworks.com/debug/cdn-not-caching) for diagnosis.
- [HTTP compression](https://howhttpworks.com/guides/http-compression) for gzip, Brotli and server configuration.
