Content Negotiation

Content Negotiation and Response Headers

One resource can have several representations. res.format() inspects Accept, runs the branch whose type wins, and — the part people forget by hand — sets Vary: Accept so caches key them separately. The second route stages headers and a cookie before sending a body.

Negotiating a representation, and staging headers before the bodyJavaScript
app.get('/books/42', (req, res) => {
  res.format({
    'application/json': () => res.json(book),
    'text/html': () => res.send(`<h1>${book.title}</h1>`),
    'text/csv': () => res.type('csv').send('id,title\n42,Dune\n'),
    default: () => res.status(406).send('Not Acceptable')
  });
});
app.get('/headers', (req, res) => {
  res.set({ 'Cache-Control': 'public, max-age=60', 'X-Total-Count': '137' });
  res.append('Link', '</books?page=2>; rel="next"');
  res.type('json').cookie('sid', 'a1b2c3', { maxAge: 900000, httpOnly: true });
  res.json({ contentType: res.get('Content-Type'), sent: res.headersSent });
});
Output
$ curl -s -D - -H 'Accept: text/html' .../books/42
HTTP/1.1 200 OK    Vary: Accept    Content-Type: text/html; charset=utf-8
$ curl -s -D - -H 'Accept: application/xml' .../books/42
HTTP/1.1 406 Not Acceptable    Vary: Accept
$ curl -s -D - .../headers
Cache-Control: public, max-age=60      X-Total-Count: 137
Link: </books?page=2>; rel="next"      Content-Type: application/json; charset=utf-8
Set-Cookie: sid=a1b2c3; Max-Age=900; Path=/; Expires=Thu, 17 Sep 2026 17:54:52 GMT; HttpOnly
{"contentType":"application/json; charset=utf-8","sent":false}

The format keys may be extensions (json, html) or full MIME types, and are tried in the order the client ranked them, not the order you wrote them. Without a default branch Express 24,430 raises its own 406 into your error middleware; supplying one keeps the payload in your control.

res.set() takes a name and value or a whole object, res.append() adds a value to a repeatable header, res.type() sets Content-Type from an extension, and res.get() reads it back. res.cookie() serializes Set-Cookie, converting maxAge from milliseconds to the seconds the header wants (900000 became Max-Age=900) and adding Expires and Path. res.clearCookie() re-sends the cookie with a 1970 expiry and, in Express 5, ignores any maxAge or expires; its path and domain must match those used to set it. Reading cookies back needs cookie-parser 2,030 (Setting and Reading Cookies).

The sent: false is the point: nothing had been written when res.json() serialized that object, because res.headersSent flips only as the head leaves — which is why header changes must precede the send. And res.vary(field) is the manual form of what res.format() did above, for a body that varies by header.