Guide
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.
On this page
- One resource, several representations
- Quality values: score candidates, not header positions
- 406 or a default: make it an endpoint policy
- Vary separates cache variants, if the CDN supports it
- Real server examples
- Express: req.accepts()
- Django: get_preferred_type(), with Vary on the view
- nginx: map is a language hint, not a q-value parser
- Translations for SEO: separate URLs and hreflang
- API versions: media types or URLs
- Client Hints: request only what affects the response
- Probe every offered variant and the rejection path
- Related
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-LanguageandAccept-Encoding. The server picks from the variants it actually has, labels the result withContent-Type,Content-LanguageorContent-Encoding, and lists the request headers it used inVary. Decide up front whether a mismatch gets a default or406, check that your CDN keys on those headers, and give translations you want indexed their own URLs.
One resource, several representations
The glossary entry 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:
Acceptlists media types such asapplication/jsonandtext/html. Label the result withContent-Type.Accept-Languagelists language preferences such asfr-CA, fr;q=0.9, en;q=0.5. Label the audience language withContent-Language.Accept-Encodinglists content codings such asbrandgzip. If you apply one, label it withContent-Encoding; an uncompressed response normally leaves that header out.
MDN calls this server-driven 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:
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/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 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). 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:
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.
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.
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). For configuration, see MDN’s encoding reference and HTTP compression.
406 or a default: make it an endpoint policy
RFC 9110 §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. 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. 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 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, 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 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 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() returns the best match from the types you offer, or false. res.vary() adds a field to Vary without duplicating it:
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. It returns None when none of the offered types match. Add vary_on_headers so Django’s cache knows the response depends on Accept:
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 goes in the http context. This one picks French when the header starts with an unweighted French tag, and English otherwise:
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 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:
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; 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 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:
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:
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 for the short definition.
- Accept, Accept-Language and Accept-Encoding for field syntax.
- Vary and HTTP headers and caching for cache behavior.
- 406 Not Acceptable and CDN not caching for diagnosis.
- HTTP compression for gzip, Brotli and server configuration.
Frequently asked questions
What is proactive content negotiation?
The client sends preferences in its request and the server chooses an available representation before replying. Accept selects a media type, Accept-Language a language, and Accept-Encoding a content coding.
Does the highest q-value always win?
It ranks client preferences, but the server can only select variants it has. For Accept, use the most specific matching range to determine each variant’s quality first. Server policy decides among equally acceptable choices.
Must an unsupported Accept header produce 406?
No. RFC 9110 allows the server to disregard an unsatisfiable negotiation header and send a default instead. A strict API can return 406 so the client discovers the mismatch.
Does Vary: Accept-Language automatically configure a CDN?
No. Verify the CDN’s supported Vary fields and cache rules. Cloudflare does not consider general Vary values by default; configured exceptions include Cache Rules Vary, Vary for images and Accept-Encoding.
Should translated pages share one URL?
Google recommends separate URLs for language versions and annotations such as hreflang. Googlebot requests do not set Accept-Language, so header-only variants can go undiscovered.
How do I select JSON or HTML in Django?
Django 5.2 added request.get_preferred_type(). Pass the offered media types, handle None as an explicit fallback or 406, and decorate a cacheable view with vary_on_headers("Accept").
Sources
- MDN: Content negotiationdeveloper.mozilla.org
- MDN: Acceptdeveloper.mozilla.org
- MDN: Accept-Languagedeveloper.mozilla.org
- MDN: Accept-Encodingdeveloper.mozilla.org
- MDN: Quality valuesdeveloper.mozilla.org
- RFC 9110: Content Negotiationrfc-editor.org
- RFC 9111: Calculating Cache Keys with Varyrfc-editor.org
- Cloudflare: Origin Cache Controldevelopers.cloudflare.com
- Google: Managing multi-regional and multilingual sitesdevelopers.google.com
- Express: Request accepts APIexpressjs.com
- Express: Response vary APIexpressjs.com
- Django: HttpRequest.get_preferred_typedocs.djangoproject.com
- nginx: map directivenginx.org
- RFC 6839: JSON structured syntax suffixrfc-editor.org
- MDN: Accept-CHdeveloper.mozilla.org
Related
Accept Header
Learn how the Accept header tells servers which content types (JSON, HTML, XML) your client can handle. Master content negotiation and quality values.
Content Negotiation
Content negotiation lets one URL serve different formats, languages or encodings based on Accept headers. See q-values, Vary, 406 and the caching traps.
Accept-Language Header
Learn how the Accept-Language header tells servers which languages your client prefers for localized content. Understand language tags and quality values.
HTTP Compression: gzip, Brotli and Zstandard Setup
How HTTP compression works: Accept-Encoding negotiation, gzip vs Brotli vs zstd, nginx, Caddy, Express and Cloudflare config, BREACH, and measuring with curl.