Content Security Policy in the Next.js App Router: Nonces, strict-dynamic, and the Static-Rendering Trade-Off
Table of Contents
About the Author

Ahmed Mahmoud
Author & Developer
Software engineer passionate about web development and user experience design.
Founder of Devya · eng-ahmed.com ↗A Content Security Policy (CSP) is an HTTP response header that tells the browser which script, style, and connection sources a document may use. In the Next.js App Router, the only script policy that survives the framework's runtime chunk loading is a per-request nonce combined with 'strict-dynamic'. The cost we did not budget for: generating that nonce in middleware.ts and reading it with headers() opts every matched route out of static rendering.
Key takeaways
A CSP nonce is a per-request random token that appears both in the script-src directive and as a nonce attribute on every allowed script tag. Because it must never repeat, HTML carrying a nonce cannot be cached — which is exactly why Next.js drops the route to dynamic rendering.
A host allowlist cannot secure a Next.js application. The App Router emits an inline bootstrap payload and then creates further script elements at runtime, so script-src 'self' both fails to allow the inline payload and fails to distinguish framework chunks from any other same-origin file.
'strict-dynamic' propagates trust from a nonce-allowed script to any script element that script creates programmatically. It is what makes chunk loading work without enumerating chunk URLs.
Keeping 'unsafe-inline' and https: in script-src is a deliberate fallback, not a hole. A browser that understands nonces ignores 'unsafe-inline', and a browser that understands 'strict-dynamic' ignores every host expression in the same directive.
Ship Content-Security-Policy-Report-Only first. The enforcing header turns a policy mistake into a blank page; the report-only header turns the same mistake into a log line.
What does a Content Security Policy actually stop?
A Content Security Policy does not stop injection. It stops execution. If an attacker gets a script tag into a comment field that we render with dangerouslySetInnerHTML, the markup still lands in the DOM — but a policy without 'unsafe-inline' means the browser refuses to run it and emits a violation report instead.
Four directives paid for themselves in our applications before we touched script-src at all, because none of them require a nonce and none of them break anything. object-src 'none' removes the legacy plugin vector. base-uri 'self' stops an injected base tag from silently repointing every relative URL on the page. form-action 'self' stops an injected form from posting credentials to another origin. frame-ancestors 'none' is the modern replacement for X-Frame-Options.
Those four can live in the headers() function in next.config.js and stay fully cacheable. The expensive directive is script-src — the one that requires a nonce.
Why does Next.js need a nonce instead of a domain allowlist?
A domain allowlist cannot express what the App Router does at runtime. Next.js serializes the React Server Component payload into inline script tags on the document, and its client runtime then creates additional script elements to fetch route chunks on demand. script-src 'self' blocks the inline payload outright, and even if it did not, 'self' would happily execute any same-origin URL — including a user-uploaded file served from our own domain.
'strict-dynamic' is the CSP Level 3 keyword that fixes this. It says that any script element created by an already-trusted script inherits that trust. The inline bootstrap gets its trust from the nonce, and every chunk it loads afterwards inherits it. No chunk hashes, no build-time URL enumeration.
The counterintuitive part is what 'strict-dynamic' switches off. In a browser that implements it, all host-source expressions in the same directive — 'self', https:, a literal CDN domain — are ignored. That is why the recommended policy still lists them: they are a graceful degradation path for older browsers, not additional permission for modern ones. The same logic applies to 'unsafe-inline', which any nonce-aware browser discards the moment a nonce is present in the directive.
How do we generate and propagate a CSP nonce in the App Router?
The nonce is generated once per request in middleware.ts, written to both the request headers and the response headers, and read back in a Server Component with headers(). Writing it onto the request matters: Next.js looks for a content-security-policy request header, extracts the nonce from it, and applies that nonce to the script tags it emits itself. Skip that step and the framework's own bootstrap is blocked by our own policy.
In practice the middleware builds the nonce with crypto.randomUUID() encoded as base64, assembles the directive list into a single string, sets x-nonce and content-security-policy on a cloned Headers object passed to NextResponse.next({ request: { headers } }), and sets the same policy on the response.
Reading it in the root layout is two lines. headers() returns a promise in Next.js 15 and later, so it must be awaited, and next/script forwards a nonce prop straight onto the emitted tag.
Why did our static pages turn dynamic after we added CSP?
Because a nonce is only a security control while it is unpredictable, and a cached HTML response hands the same nonce to every visitor. A reused nonce is functionally identical to 'unsafe-inline': an attacker who can read one page's markup learns the token that unlocks script execution on every other copy of it. Next.js enforces the safe interpretation by treating headers() as a dynamic API — the moment the root layout calls it, every route under that layout renders per request.
That is a real bill. Marketing pages that were prerendered at build time became server-rendered on every hit, and Partial Prerendering could no longer treat the shell as static.
Which CSP strategy should we choose?
Option one — nonce plus 'strict-dynamic' on every route. Script safety: strongest, no inline injection executes anywhere. Rendering cost: every matched route renders dynamically.
Option two — nonce scoped to authenticated and form-handling routes, with a static header policy elsewhere. Script safety: strong where user input is rendered. Rendering cost: marketing and documentation pages stay static.
Option three — no nonce, script-src 'self' 'unsafe-inline'. Script safety: weak, injected inline script still runs. Rendering cost: none, the site stays fully static.
We land on option two for content-heavy sites and option one for anything behind a login. Scoping is done through the middleware matcher, and the Next.js documented example additionally excludes prefetch requests so that a prefetched RSC payload does not burn a nonce it will never use.
What breaks in development and with CSS-in-JS?
Development needs 'unsafe-eval' in script-src. React Fast Refresh and eval-based source maps both compile strings at runtime, so a production-grade policy produces a console full of EvalError the moment the dev server starts. Gate it on process.env.NODE_ENV rather than shipping it everywhere.
style-src is where we stopped fighting. Next.js inlines critical CSS as style elements, and CSS-in-JS libraries inject more at runtime, frequently from code paths that never see our nonce. We keep 'unsafe-inline' in style-src deliberately: style injection is a far weaker vector than script injection, and the alternative is a policy that breaks on every dependency upgrade. If a threat model demands a style nonce, styled-components reads it from the __webpack_nonce__ global and Emotion accepts one via createCache({ nonce }).
Three more that caught us. next/image blur placeholders are data: URLs, so img-src needs data: and usually blob:. Analytics beacons need their host in connect-src, not script-src, because the script is loaded but the beacon is a separate fetch. Embedded Stripe or YouTube iframes need frame-src, which does not inherit from default-src once directives are listed explicitly.
How should we roll out CSP without breaking production?
Send the identical policy under Content-Security-Policy-Report-Only first and leave it there for a full traffic cycle — at minimum a week, so weekday and weekend behaviour both show up. The report-only header instructs the browser to evaluate the policy and report violations without blocking anything, which converts an outage into a log stream.
Collecting the reports takes one Route Handler. The legacy report-uri directive posts a single JSON object with content type application/csp-report; the newer Reporting API uses a Reporting-Endpoints response header plus a report-to directive and posts batched arrays as application/reports+json. Browsers are split across both, so we send both directives and normalise on arrival.
Filter browser extensions before alerting on anything. On the first day of collection, the overwhelming majority of reports came from chrome-extension: and moz-extension: origins injecting scripts into pages — noise nobody can fix and nobody should be paged about. What remains after that filter is the actual list of things the policy would have broken.
FAQ
Q: Does a CSP nonce protect us if we render untrusted HTML with dangerouslySetInnerHTML?
A: For script execution, yes. An injected script tag without the matching nonce will not run, and inline event handler attributes such as onerror are blocked by the same absence of 'unsafe-inline'. It does not stop the markup itself from rendering, so a CSP is not a substitute for sanitising with a library like DOMPurify.
Q: Can a nonce-based CSP work with a statically exported Next.js site?
A: No. A nonce must be generated per request, and a static export has no request-time compute. The options are a hash-based script-src — which requires extracting the hashes of the inline bootstrap after every build, since they change per build — or serving the export behind an edge function that injects the header.
Q: Does adding 'strict-dynamic' make the policy weaker?
A: It makes it narrower. 'strict-dynamic' causes conforming browsers to ignore every host allowlist entry in script-src, so a CDN domain that was previously trusted wholesale is no longer trusted at all. Trust flows only from the nonce outward.
Q: Is the X-Frame-Options header still needed?
A: frame-ancestors supersedes it in every current browser, and where both are present frame-ancestors wins. Keeping X-Frame-Options: DENY alongside it costs nothing and covers very old clients.
Q: Should the CSP live in next.config.js or in middleware?
A: Put every static directive — frame-ancestors, object-src, base-uri, form-action — in the headers() function in next.config.js, because those responses stay cacheable. Only a policy containing a per-request nonce needs middleware, and only on the routes that require it.
Further Reading
Full-Stack Engineering
Passkeys in Production: What We Learned Shipping WebAuthn, Conditional UI, and RP ID Rules
A passkey is a WebAuthn credential permanently bound to one Relying Party ID. Our team traced almost every passkey bug back to three details: the RP ID matching rules, using an email as user.id, and expecting the browser autofill prompt without conditional mediation. These are our field notes from shipping passkeys on Next.js.
Full-Stack Engineering
What Changed in Zod 4, and How We Migrated Production Schemas
Zod 4 is a rewrite of the TypeScript-first schema library: top-level string formats such as z.email(), one unified error option, new error-reading helpers, and a tree-shakeable zod/mini build. We break down what changed and how our team migrated production schemas.