JSX escapes every interpolated value, so {user.bio} cannot introduce a tag whatever the user typed. The holes are where you opt out: dangerouslySetInnerHTML, an href built from user input (javascript: URLs still execute), and any value that reaches a <script>. A Content Security Policy is the layer underneath: its strict form allows a script only if it carries a nonce, which means a fresh nonce per request, minted by the proxy.
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 'self';
form-action 'self'; frame-ancestors 'none';`.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);
response.headers.set("X-Content-Type-Options", "nosniff");
response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");Setting the policy on the request headers is what makes it work: Next.js 10,514 parses that header while rendering and stamps the nonce onto the framework scripts, the page bundles and any <Script nonce>, while 'strict-dynamic' extends that trust to whatever those scripts load.
$ curl -s -D - -o /dev/null localhost:3319/login | head -4 content-security-policy: default-src 'self'; script-src 'self' 'nonce-YzJiODA0MTEtZDNlZi00YTVlLWFkMGEtNWYwZWQ5YTBiZjdk' 'strict-dynamic'; ... x-content-type-options: nosniff
Two things break in practice, and both showed up the first time this application was built. A nonce cannot be baked into a prerendered page, so while /login was static the header carried a nonce, the HTML carried none, and Chrome 1 refused every bundle: "Loading the script .../chunks/2dhrl2umv5vzw.js violates the following Content Security Policy directive". Adding await connection() made the route dynamic — ƒ /login in the build output instead of ○ — and the nonce appeared, matching the header. A nonce-based CSP therefore means dynamic rendering everywhere, which rules out partial prerendering; experimental.sri offers hash-based integrity instead.
The second break is quieter: a nonce authorizes <style> elements, not style= attributes, so a React 7,897 style={{ ... }} prop is refused and the page renders unstyled. Move those rules into an imported CSS file rather than reaching for 'unsafe-inline', which would undo the policy.