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.
What a CSP actually does
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.
Why nonces beat allowlists and unsafe-inline
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.
Generating a per-request nonce in middleware
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.
Why nonces force dynamic rendering
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.
Report-Only rollout and violation reporting
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.
Nonce vs hash vs unsafe-inline
| 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.

