Skip to main content

headers

A server-side function that defines HTTP headers to be sent with the document response. Only runs on document requests, not data requests.

Signature

export function headers(args: HeadersArgs): Headers | HeadersInit
args
HeadersArgs
required
Arguments passed to the headers function
return
Headers | HeadersInit
Can return:
  • A Headers object
  • A HeadersInit object (plain object, array of tuples)

Basic Example

export function headers() {
  return {
    "Cache-Control": "public, max-age=3600",
  };
}

export async function loader() {
  const data = await fetchData();
  return { data };
}

export default function Component() {
  // Route rendering
}

Using Loader Headers

export async function loader() {
  const response = await fetch("https://api.example.com/data");
  const data = await response.json();
  
  return Response.json(
    { data },
    {
      headers: {
        "Cache-Control": response.headers.get("Cache-Control"),
        "X-Custom-Header": "value"
      }
    }
  );
}

export function headers({ loaderHeaders }: HeadersArgs) {
  // Forward cache headers from loader
  return {
    "Cache-Control": loaderHeaders.get("Cache-Control"),
    "X-Custom-Header": loaderHeaders.get("X-Custom-Header"),
  };
}

Merging Parent Headers

export function headers({ parentHeaders }: HeadersArgs) {
  // Start with parent headers
  const headers = new Headers(parentHeaders);
  
  // Override or add specific headers
  headers.set("Cache-Control", "public, max-age=1800");
  headers.set("X-Route-Id", "product-detail");
  
  return headers;
}

Conditional Headers Based on Action

export function headers({ actionHeaders, loaderHeaders }: HeadersArgs) {
  // If action was called, use action headers
  if (actionHeaders) {
    return {
      "Cache-Control": "no-cache",
      "X-Action-Status": actionHeaders.get("X-Action-Status"),
    };
  }
  
  // Otherwise use loader headers
  return {
    "Cache-Control": loaderHeaders.get("Cache-Control") || "public, max-age=3600",
  };
}

Error Response Headers

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"),
  };
}

Security Headers

export function headers() {
  return {
    "X-Frame-Options": "DENY",
    "X-Content-Type-Options": "nosniff",
    "Referrer-Policy": "strict-origin-when-cross-origin",
    "Permissions-Policy": "geolocation=(), microphone=(), camera=()",
  };
}

Cache Control Strategies

// Static content - cache aggressively
export function headers() {
  return {
    "Cache-Control": "public, max-age=31536000, immutable",
  };
}

// Dynamic content - cache with revalidation
export function headers() {
  return {
    "Cache-Control": "public, max-age=60, stale-while-revalidate=300",
  };
}

// Private content - no cache
export function headers() {
  return {
    "Cache-Control": "private, no-cache, no-store, must-revalidate",
  };
}

// CDN with browser cache
export function headers() {
  return {
    "Cache-Control": "public, max-age=300, s-maxage=3600",
  };
}

Content Security Policy

export function headers() {
  const csp = [
    "default-src 'self'",
    "script-src 'self' 'unsafe-inline' https://trusted-cdn.com",
    "style-src 'self' 'unsafe-inline'",
    "img-src 'self' data: https:",
    "font-src 'self' data:",
    "connect-src 'self' https://api.example.com",
  ].join("; ");

  return {
    "Content-Security-Policy": csp,
  };
}

Dynamic Headers

export async function loader({ context }: Route.LoaderArgs) {
  const data = await fetchData();
  const cacheTime = data.isPublished ? 3600 : 60;
  
  return Response.json(
    { data },
    {
      headers: {
        "Cache-Control": `public, max-age=${cacheTime}`,
        "X-Published": String(data.isPublished),
      }
    }
  );
}

export function headers({ loaderHeaders }: HeadersArgs) {
  return {
    "Cache-Control": loaderHeaders.get("Cache-Control"),
    "X-Published": loaderHeaders.get("X-Published"),
  };
}

Setting Cookies

export async function action({ request }: Route.ActionArgs) {
  const formData = await request.formData();
  const theme = formData.get("theme");
  
  return Response.json(
    { success: true },
    {
      headers: {
        "Set-Cookie": `theme=${theme}; Path=/; Max-Age=31536000; SameSite=Lax`,
      }
    }
  );
}

export function headers({ actionHeaders }: HeadersArgs) {
  const headers = new Headers();
  
  if (actionHeaders?.has("Set-Cookie")) {
    headers.set("Set-Cookie", actionHeaders.get("Set-Cookie"));
  }
  
  return headers;
}

Best Practices

Keep header logic close to the data:
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"),
  };
}
React Router uses headers from the deepest matching route:
// Route: /products/:id
// Headers from product detail route are used, not /products

// app/routes/products.tsx
export 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" };
}
These are automatically managed by React Router:
// ❌ Don't do this
export function headers() {
  return {
    "Content-Type": "text/html",
    "Content-Length": "1234",
  };
}

// ✅ Set other headers
export function headers() {
  return {
    "Cache-Control": "public, max-age=3600",
    "X-Custom-Header": "value",
  };
}
Remember that headers bubble up through parent routes:
// app/root.tsx
export function headers() {
  return {
    "X-Frame-Options": "DENY", // Applied to all routes
  };
}

// app/routes/admin.tsx
export function headers({ parentHeaders }: HeadersArgs) {
  const headers = new Headers(parentHeaders);
  headers.set("X-Admin", "true");
  return headers;
}

See Also

Build docs developers (and LLMs) love