export function headers({ errorHeaders, loaderHeaders }: HeadersArgs) { // If there was an error, use error headers if (errorHeaders) { return { "Cache-Control": "no-cache", "X-Error": "true", }; } return { "Cache-Control": loaderHeaders.get("Cache-Control"), };}
export async function loader() { const data = await fetchData(); return Response.json( { data }, { headers: { "Cache-Control": "public, max-age=3600", } } );}export function headers({ loaderHeaders }: HeadersArgs) { // Simply forward loader headers return { "Cache-Control": loaderHeaders.get("Cache-Control"), };}
Leaf route headers win
React Router uses headers from the deepest matching route:
// Route: /products/:id// Headers from product detail route are used, not /products// app/routes/products.tsxexport function headers() { return { "Cache-Control": "max-age=3600" };}// app/routes/products.$id.tsx export function headers() { // These headers are used for /products/123 return { "Cache-Control": "max-age=300" };}
Don't set Content-Type or Content-Length
These are automatically managed by React Router:
// ❌ Don't do thisexport function headers() { return { "Content-Type": "text/html", "Content-Length": "1234", };}// ✅ Set other headersexport function headers() { return { "Cache-Control": "public, max-age=3600", "X-Custom-Header": "value", };}
Consider the full header hierarchy
Remember that headers bubble up through parent routes:
// app/root.tsxexport function headers() { return { "X-Frame-Options": "DENY", // Applied to all routes };}// app/routes/admin.tsxexport function headers({ parentHeaders }: HeadersArgs) { const headers = new Headers(parentHeaders); headers.set("X-Admin", "true"); return headers;}