"You can serve JSON to one client, HTML to another, and a CSV download to a third — all at the same URL. The client tells you what they want via Accept; you tell them what you sent via Content-Type. The protocol handles the rest."
Two Headers and a Conversation
Content negotiation is the mechanism that lets the same resource speak multiple formats. Clients announce what they can handle; servers pick the best match. Two headers do the heavy lifting:
Accept(request) — "I can handle these formats, in this order of preference."Content-Type(response) — "Here's what I actually sent."
Three more headers handle related axes:
Accept-Encoding/Content-Encoding— compression (gzip, br, zstd).Accept-Language/Content-Language— locale (en-US, ko-KR).Accept-Charset— character set. Largely vestigial now; UTF-8 won.
q-Values: How Clients Express Preference
An Accept header isn't just a list — it's a weighted list. Each option can carry a q= parameter from 0.0 ("don't send") to 1.0 ("want most"). When absent, q=1.0 is implicit.
Accept: application/json, text/html;q=0.9, */*;q=0.5
The client is saying: "Give me JSON if you can. If not, HTML is a 90%-okay fallback. Failing that, send anything (50% okay)." The server picks the highest-q option it actually supports. If nothing matches, the server returns 406 Not Acceptable.
The Vary Header — The Cache Coordination You Cannot Skip
If your server returns different responses for the same URL depending on a request header (Accept, Accept-Encoding, Authorization, Cookie), you MUST tell caches via the Vary response header. Otherwise the first response (say, JSON for a logged-in user) gets cached and served to subsequent requests that expected HTML (or no auth).
Vary: Accept, Accept-Encoding, Authorization
This tells the CDN: "This response is keyed by URL plus these three request headers. Don't serve this cached entry to a request whose Accept header differs." Forgetting Vary is the #1 source of "the wrong user saw the previous user's data" CDN bugs.
/users.json vs /users.html), which couples representation to URI and breaks bookmarks when you add a new format. With it, one URI serves all clients, and the wire layer does the matchmaking.Server-Driven vs Agent-Driven Negotiation
Server-driven (the normal case) — the server reads Accept and picks. Most REST APIs work this way.
Agent-driven — the server replies with 300 Multiple Choices and a list of available formats; the client picks. Rare in practice; clients hate making that extra round trip.
Reactive negotiation (Vary-based) — server returns one format and uses Vary so caches do the right thing. This is what most production systems actually do, blending server-driven with cache coordination.
cwkPippa's Negotiation Reality
application/json — it's a single-format API, so content negotiation is degenerate (the server ignores Accept and always returns JSON). The frontend's static assets do the full dance: index.html negotiates language via Accept-Language, the CDN serves gzip or brotli depending on Accept-Encoding, and the response carries Vary: Accept-Encoding so the CDN keeps separate cached entries per encoding. SSE responses use Content-Type: text/event-stream — the only place cwkPippa serves a non-JSON content type.