Next.js Headers: App Router vs Pages Router

This guide is part of the Vercel & Next.js Security Header Management reference. Next.js ships two routing models — the App Router (app/) and the legacy Pages Router (pages/) — and they emit security headers through partly overlapping, partly divergent mechanisms. The next.config.js headers() array behaves identically in both, but per-request work such as minting a Content-Security-Policy nonce diverges sharply: the App Router reads the request inside React Server Components, while the Pages Router has no server render you can inject into per request without getServerSideProps. This page maps exactly where headers live in each model, what breaks under output: 'export', and how to verify the result.

Configuration Syntax & Exact Values

Both routers share one static header surface: the async headers() export in next.config.js. It runs at build time, returns an array of { source, headers } objects, and cannot read the incoming request — so it emits identical values on every matching response.

// next.config.js — identical semantics in App Router and Pages Router
module.exports = {
  async headers() {
    return [
      {
        // source uses Next.js path syntax, not raw RegExp. '/(.*)' = every route.
        source: '/(.*)',
        headers: [
          // 2 years + preload eligibility. Applies to both routers equally.
          { key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains; preload' },
          { key: 'X-Content-Type-Options', value: 'nosniff' },
          // frame-ancestors 'none' is the modern replacement for X-Frame-Options.
          { key: 'X-Frame-Options', value: 'DENY' },
          { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
        ],
      },
    ];
  },
};

There is no always flag in the Next.js API — the equivalent guarantee is implicit. Next.js applies headers() entries to every response it generates for a matching source, including error responses (404, 500) rendered by the framework. This is the counterpart to Nginx’s always on add_header and Apache’s always condition on Header set: without it, those servers drop headers on non-2xx responses; Next.js never drops them for framework-rendered routes.

The divergence starts with per-request values. A nonce-based CSP must change on every response, so it cannot live in headers() at all — in either router it belongs in middleware.ts. The Edge middleware runs before both routers and can read the request and set response headers dynamically.

// middleware.ts — runs identically for App Router and Pages Router requests
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
  const nonce = Buffer.from(crypto.randomUUID()).toString('base64');
  const csp = [
    `default-src 'self'`,
    `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
    `style-src 'self' 'nonce-${nonce}'`,
    `object-src 'none'`,
    `base-uri 'none'`,
    `frame-ancestors 'none'`,
  ].join('; ');

  // Forward the nonce to the render layer via a request header.
  const requestHeaders = new Headers(request.headers);
  requestHeaders.set('x-nonce', nonce);

  const response = NextResponse.next({ request: { headers: requestHeaders } });
  // Set the actual CSP on the RESPONSE. This is the value the browser enforces.
  response.headers.set('Content-Security-Policy', csp);
  return response;
}

export const config = { matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'] };

The key mechanic: middleware sets the header on the response (what the browser sees) and forwards the same nonce inward on a request header (x-nonce) so the render layer can stamp it onto <script nonce> tags. How the render layer reads that request header is exactly where the two routers split.

Header surfaces in App Router vs Pages Router Comparison of where static and per-request headers are set in each Next.js routing model. Where headers live in each router App Router (app/) Pages Router (pages/) next.config.js headers() static values, both routers next.config.js headers() static values, both routers middleware.ts per-request CSP + nonce middleware.ts per-request CSP + nonce RSC reads headers() nonce into layout <Script> getServerSideProps read x-nonce, pass as prop no per-route API for headers res.setHeader in GSSP works too
Static headers converge in next.config.js; per-request nonce injection is where the routers diverge.

Server-Side Configuration

App Router (app/)

The App Router renders on the server via React Server Components. Any server component — including the root layout — can call headers() from next/headers to read the incoming request headers, which is exactly where the middleware-forwarded x-nonce lands. Stamp it onto every <script> you control via next/script.

// app/layout.tsx — App Router nonce consumption
import { headers } from 'next/headers';
import Script from 'next/script';

export default async function RootLayout({ children }: { children: React.ReactNode }) {
  // headers() here reads the request headers forwarded by middleware.ts.
  const nonce = (await headers()).get('x-nonce') ?? '';
  return (
    <html lang="en">
      <body>
        {children}
        {/* Next.js also auto-applies the nonce to its own bootstrap scripts
            when a nonce-bearing CSP is present on the request. */}
        <Script nonce={nonce} src="/analytics.js" strategy="afterInteractive" />
      </body>
    </html>
  );
}

Two App Router facts that trip people up. First, export const metadata and generateMetadata() control <meta>, <title>, and link tags only — there is no header field in the Metadata API. You cannot set Content-Security-Policy or Strict-Transport-Security from metadata; those are HTTP response headers and must come from headers() or middleware. Second, reading headers() inside a component opts that route out of static rendering into dynamic (per-request) rendering, which is correct and necessary for a per-request nonce but changes the caching profile.

Pages Router (pages/)

The Pages Router has no server component tree. Middleware still runs and still sets the response CSP, but to consume the forwarded nonce in the rendered HTML you need a server render per request — that means getServerSideProps, which exposes both req (to read x-nonce) and res (to set headers directly if you prefer).

// pages/index.tsx — Pages Router nonce consumption
import type { GetServerSideProps } from 'next';
import Script from 'next/script';

export const getServerSideProps: GetServerSideProps = async ({ req }) => {
  // Read the nonce middleware forwarded on the request header.
  const nonce = (req.headers['x-nonce'] as string) ?? '';
  return { props: { nonce } };
};

export default function Home({ nonce }: { nonce: string }) {
  return <Script nonce={nonce} src="/analytics.js" strategy="afterInteractive" />;
}

For custom document-level scripts, mirror the nonce into pages/_document.tsx, which also supports reading it off getInitialProps context. Pages using getStaticProps (SSG) cannot receive a per-request nonce because they are not rendered per request — for those routes, fall back to a hash-based CSP set in headers().

Nonce request/response flow through middleware and render layer Sequence showing the nonce forwarded inward on a request header and the CSP set outward on the response. Browser middleware.ts Render (RSC / GSSP) Response GET / mint nonce x-nonce (request header) stamp <Script nonce> HTML with nonce CSP: script-src 'nonce-...' (response header) browser enforces CSP against nonce
The nonce travels inward on a request header; the enforced CSP travels outward on the response header.

Static export (output: ‘export’)

Setting output: 'export' in next.config.js produces a pure static out/ directory with no Node server and no Edge runtime. This changes the options fundamentally.

// next.config.js — static export DISABLES runtime header sources
module.exports = {
  output: 'export',
  // headers() is IGNORED under output: 'export' — there is no server to emit them.
  // middleware.ts is IGNORED — there is no runtime to run it.
};

Under static export, headers() and middleware.ts are both inert — Next.js emits a build warning and drops them. Every security header must instead be set by whatever serves the static files: your CDN, Nginx, Apache, or Cloudflare. Per-request nonces are impossible on statically exported pages; use a hash-based CSP computed at build time instead.

# Serving a Next.js static export from Nginx — always flag is mandatory
server {
    root /var/www/out;
    # 'always' emits the header on ALL responses, including 404s Nginx returns
    # for missing static files. Without it, error responses ship bare.
    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header Content-Security-Policy "default-src 'self'; object-src 'none'; base-uri 'none'" always;
}

Diagnostic & Verification Steps

Verify the static headers() values with curl -I. The header must appear on both the App Router and Pages Router routes and on framework error pages.

curl -sI https://example.com/ | grep -i strict-transport
# Expected:
# strict-transport-security: max-age=63072000; includeSubDomains; preload

# Confirm headers survive on a 404 (framework-rendered error page):
curl -sI https://example.com/does-not-exist | grep -iE 'strict-transport|content-security'
# Expected: both headers still present on the 404 response

For the per-request nonce, fetch the same URL twice and confirm the CSP nonce- token differs each time — proof middleware is minting per request rather than caching a static value.

for i in 1 2; do
  curl -sI https://example.com/ | grep -io "nonce-[A-Za-z0-9+/=]*" | head -1
done
# Expected: two DIFFERENT nonce values, e.g.
# nonce-ZjQ4YmE1...
# nonce-N2ExYzdl...

Then confirm the response CSP nonce matches the nonce stamped on the rendered <script> tag — a mismatch means middleware and the render layer forwarded different values, and the browser will block the script.

URL=https://example.com/
HDR=$(curl -sD - -o /dev/null "$URL" | grep -io "nonce-[A-Za-z0-9+/=]*" | head -1)
BODY=$(curl -s "$URL" | grep -io 'nonce="[A-Za-z0-9+/=]*"' | head -1)
echo "header: $HDR"; echo "body:   $BODY"
# Expected: the token in header: matches the token inside body:

In Chrome DevTools, open the Console after loading the page. A working nonce shows no CSP violations. A mismatch prints: Refused to execute inline script because it violates the following Content Security Policy directive: "script-src 'nonce-...'". The Network panel’s response Headers tab shows the enforced content-security-policy value for the document request.

Decision tree: which header mechanism to use Decision tree routing a header requirement to next.config headers, middleware, or an upstream server. output: 'export' ? is there a runtime? yes (static) no (server/edge) CDN / Nginx / Apache hash-based CSP only value per-request ? nonce needed? no yes next.config headers() static, both routers middleware.ts + RSC / GSSP
Route the requirement: static export forces an upstream server; a runtime lets static headers use headers() and per-request nonces use middleware.

Edge Cases, Security Implications & Safe Rollback

Trap 1 — _next/static and CSP mismatch. Middleware matched too broadly re-mints a nonce for cached static assets, but the App Router’s build-time HTML for those chunks carries the nonce from the render request, not the asset request. Exclude _next/static and _next/image from the middleware matcher (as in the config above) so hashed immutable assets are never re-nonced. If you skip this, cached pages served from a different nonce request fail CSP enforcement intermittently.

Trap 2 — duplicate CSP from both mechanisms. Setting a CSP in headers() and in middleware produces two Content-Security-Policy headers. Browsers enforce the intersection of all CSP headers — the effective policy becomes the strictest combination, which usually breaks the page because a static headers() CSP lacks the per-request nonce. Set CSP in exactly one place: middleware when you need nonces, headers() when you do not.

Trap 3 — App Router dynamic opt-in surprise. Reading headers() in a layout forces dynamic rendering for the whole subtree, disabling static optimization and the full-route cache. This is required for nonces but has a cost; if a route does not need a per-request value, do not read request headers in it. The Pages Router equivalent is that getStaticProps pages cannot receive a nonce at all — they must use a hash CSP.

Roll back safely by moving from enforcement to report-only. Content-Security-Policy-Report-Only evaluates the policy and reports violations without blocking anything, so a broken nonce wiring surfaces in reports instead of breaking production.

// middleware.ts — report-only during rollout
response.headers.set('Content-Security-Policy-Report-Only', csp + '; report-uri /csp-report');
// Swap the key to 'Content-Security-Policy' only after reports are clean.

To revert entirely, delete middleware.ts (or narrow its matcher to []) and redeploy; static headers() values keep flowing untouched. Because next.config.js header changes are build-time, a rollback there requires a redeploy — there is no runtime toggle.

Conclusion

Deploy in stages: prove the static headers() values on staging with curl -I, then wire per-request nonces through middleware behind Content-Security-Policy-Report-Only with a short-lived HSTS max-age and no preload. Once reports are clean and the header nonce matches the rendered <script nonce>, flip to enforcing Content-Security-Policy, raise max-age to two years, and add preload for the full production rollout.

Frequently Asked Questions

Can I set a Content-Security-Policy from the App Router Metadata API? No. metadata and generateMetadata() only emit <meta>, <title>, and <link> tags — there is no field for HTTP response headers. Set CSP and every other security header through next.config.js headers() or middleware.ts.

Does next.config.js headers() work differently in App Router versus Pages Router? No — the headers() array is a framework-level feature that runs at build time and behaves identically in both routers. The divergence is only in per-request nonce consumption: RSC reads headers() from next/headers in the App Router, while the Pages Router reads req.headers in getServerSideProps.

Why are my headers gone after switching to output: 'export'? Static export produces no Node server and no Edge runtime, so both headers() and middleware.ts are ignored with a build warning. Set the headers on whatever serves the static files — your CDN, Nginx, Apache, or Cloudflare — and use a hash-based CSP because per-request nonces are impossible.

How do I confirm my per-request nonce is actually changing? Send curl -sI to the same URL twice and grep for the nonce- token; two identical values mean middleware is not running or the response is cached, and two different values confirm per-request minting. Then verify the response-header nonce matches the nonce in the rendered <script> tag.

Should I set CSP in both headers() and middleware for redundancy? No. Two Content-Security-Policy headers are enforced as their intersection, and a static headers() policy without the middleware nonce will block your scripts. Choose one source: middleware for nonce-based policies, headers() for static hash-based or nonce-free policies.