Content Security Policy in Next.js: Nonces & strict-dynamic

Photo by Severus Jones on Pexels
A nonce is a unique, unpredictable random string the server generates for a single HTTP response. It is placed in the script-src directive as nonce-VALUE and stamped onto every trusted script tag. The browser runs only scripts carrying the matching nonce, so injected scripts are blocked because an attacker cannot guess the value.
Generate it in Next.js middleware (the proxy file) before rendering, using crypto.randomUUID encoded to base64. Build the Content-Security-Policy string with nonce-VALUE plus strict-dynamic, set it as the response header, and forward the raw nonce to the renderer through a custom x-nonce request header. Next.js then applies it automatically during server-side rendering.
A nonce must be unique per request, but a statically generated page is built once at build time when no request exists. So any page carrying a nonce must be dynamically rendered. This disables static optimization and Incremental Static Regeneration, and Partial Prerendering is incompatible because the static shell has no nonce.
Yes, by default. Because nonce pages are dynamically rendered on every request, they cannot be cached at the CDN edge, which means slower first loads and higher server cost. If caching matters more, Next.js offers an experimental hash-based alternative using Subresource Integrity that keeps static output.
Start with the Content-Security-Policy-Report-Only header, which runs the same policy but blocks nothing and only reports violations. Pair it with a Reporting-Endpoints header and the report-to directive to collect reports. Fix every reported violation, and only when reports go quiet switch the header name to Content-Security-Policy to enforce it.

Photo by Severus Jones on Pexels
Key Takeaway
A Content Security Policy with a per-request nonce is the strongest way to stop cross-site scripting in Next.js: middleware generates a fresh random nonce, injects it into the script-src directive alongside strict-dynamic, and Next.js applies it automatically. The trade-off is that nonces force dynamic rendering, so pages can no longer be cached on a CDN by default.
Cross-site scripting is still one of the most common ways attackers get JavaScript running inside your users' browsers. A Content Security Policy, or CSP, is the browser-enforced allowlist that decides which scripts, styles, images, and fonts are actually permitted to load. Get it right and an injected script simply refuses to run.
In this post I walk through the approach the Next.js team recommends: a strict, nonce-based CSP with strict-dynamic, generated per request in middleware. I also cover the part most tutorials skip, which is what this costs you in caching, and how to roll it out without breaking production on day one.
A CSP is delivered as the Content-Security-Policy HTTP response header. It is a list of directives separated by semicolons, where each directive names a resource type and the sources allowed for it. For example, default-src 'self' says load everything from your own origin only, and object-src 'none' disables legacy plugins like Flash outright.
The directive that matters most for XSS is script-src. If script-src does not allow inline code, then an attacker who manages to inject a script tag into your HTML gains nothing, because the browser refuses to execute it. That single guarantee is the whole point of a strict CSP.
Older CSPs relied on host allowlists such as script-src www.example.com. Security researchers have shown these are easy to bypass, because any allowlisted domain that hosts a vulnerable library or an open redirect becomes an escape hatch. The keyword 'unsafe-inline' is worse still: it re-enables every inline script and defeats most of the protection CSP was meant to provide.
A nonce fixes this. A nonce is a unique, unpredictable random string generated for a single response. The server puts nonce-VALUE into the script-src directive and stamps the same value onto every script it trusts. The browser runs only scripts carrying the matching nonce. An attacker cannot guess a fresh random value, so injected scripts are blocked. Adding strict-dynamic then lets a trusted, nonce-bearing script load its own dependencies without you having to allowlist each one.
Because the nonce must change on every request, you generate it at the edge, in Next.js middleware (the proxy file), before the page renders. The middleware builds the CSP string, sets it as the response header, and also forwards the raw nonce to the renderer through a custom x-nonce request header.
// proxy.ts (Next.js middleware/proxy) — a fresh nonce per request
import { NextRequest, NextResponse } from 'next/server'
export function proxy(request: NextRequest) {
const nonce = Buffer.from(crypto.randomUUID()).toString('base64')
const cspHeader = `
default-src 'self';
script-src 'self' 'nonce-${nonce}' 'strict-dynamic';
style-src 'self' 'nonce-${nonce}';
img-src 'self' blob: data:;
font-src 'self';
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'none';
upgrade-insecure-requests;
`
// Collapse whitespace into a single-line header value
const csp = cspHeader.replace(/\s{2,}/g, ' ').trim()
const requestHeaders = new Headers(request.headers)
requestHeaders.set('x-nonce', nonce)
requestHeaders.set('Content-Security-Policy', csp)
const response = NextResponse.next({ request: { headers: requestHeaders } })
response.headers.set('Content-Security-Policy', csp)
return response
}Next.js does the rest automatically. During server-side rendering it parses the incoming Content-Security-Policy header, extracts the value from the nonce-VALUE pattern, and attaches that nonce to its framework scripts, page bundles, and any Script component you render. You do not hand-stamp each tag. If you need the nonce yourself, for a third-party analytics tag, read it back from the x-nonce header via the headers function in a Server Component.
Tip: in development you also need 'unsafe-eval' in script-src, because React uses eval to reconstruct server-side error stacks for better debugging. It is not required in production, so gate it behind a NODE_ENV check and never ship it live.
Here is the catch nobody mentions until it bites: a nonce must be unique per request, and a statically generated page is built once at build time when no request exists. So the moment you adopt nonce-based CSP, every page that carries the nonce must be dynamically rendered. Static optimization and Incremental Static Regeneration are disabled for those routes, and Partial Prerendering is incompatible because the static shell has no nonce.
The practical cost is caching. Dynamic pages are regenerated on every request and cannot be cached at the CDN edge by default, which means slower first loads, more server work, and higher hosting bills. If keeping static output and CDN caching matters more, Next.js offers an experimental hash-based alternative using Subresource Integrity, which computes script hashes at build time instead of nonces at request time.
Warning: if a route that should be dynamic is still being statically optimized, the nonce will be missing and your own scripts will be blocked in production. Force dynamic rendering on those pages, for example by awaiting the connection function from next/server, so the render always waits for a real request.
Never flip a strict CSP straight to enforcing on a live site. Start with the Content-Security-Policy-Report-Only header instead. It runs the exact same policy but blocks nothing; the browser only reports what it would have blocked. Pair it with a Reporting-Endpoints header and the report-to directive so violations are POSTed to your collector as JSON.
// Roll out safely: report violations first, block nothing yet
const reportOnlyCsp =
"default-src 'self'; " +
"script-src 'self' 'nonce-${nonce}' 'strict-dynamic'; " +
"object-src 'none'; base-uri 'none'; " +
"report-to csp-endpoint"
response.headers.set(
'Reporting-Endpoints',
'csp-endpoint="https://example.com/csp-reports"'
)
// Note: Report-Only observes, it does NOT enforce
response.headers.set('Content-Security-Policy-Report-Only', reportOnlyCsp)Let Report-Only run in production long enough to surface every legitimate inline script, third-party widget, and stray style you forgot about. Fix each reported violation, and only when the reports go quiet do you swap the header name to Content-Security-Policy to enforce it. The report-to directive is the modern mechanism; the older report-uri is deprecated but still worth declaring alongside it for browsers that lack report-to support.
| Approach | Rendering | CDN caching | XSS protection |
|---|---|---|---|
| Nonce + strict-dynamic | Dynamic, per request | Not cacheable by default | Strong |
| Hash / SRI | Static, build time | CDN friendly | Strong for build-time scripts |
| unsafe-inline | Static | CDN friendly | Weak, defeats CSP |
My rule of thumb: reach for nonces when you have genuinely strict requirements or handle sensitive data, and accept the dynamic-rendering cost as the price of real XSS protection. For a mostly static marketing site where caching is king, the experimental hash-based path keeps you fast while staying strict. Whichever you pick, roll it out in Report-Only first.