Skip to main content
Web development

Our CSP nonce silently disabled static rendering on all 26 routes

Tayyab Ahmad · Published 8 September 2026 · 8 min read

We shipped this site with a Content-Security-Policy that had no 'unsafe-inline' in script-src. Every request passed through a proxy that generated a fresh nonce and stamped it onto our inline scripts. On paper it was the stronger policy, and every security checklist we ran agreed.

It also meant not a single page on the site was being served from cache.

The build output said so plainly, and we had been reading past it for weeks:

0 of 26 routes prerendered

Why a nonce forces dynamic rendering

A nonce has one job: be unpredictable, and be different on every request. If an attacker can guess the nonce, the policy is decorative.

That requirement is fundamentally incompatible with prerendering. A prerendered page is HTML written once at build time and handed to every visitor unchanged. If the nonce is baked into that HTML, it is a constant, which means it is guessable, which means it is not a nonce. Next.js resolves this the only way it can: any route that reads headers() to source a nonce is marked dynamic and rendered on demand, forever.

So the nonce was not slow because of a misconfiguration. It was slow because we had asked for two things that cannot both be true.

BEFOREnonceheaders()all routes dynamic0/26 cachedAFTER'unsafe-inline'no headers() readstatic prerendering24/26 cached/contact + /newsletter/confirmstay dynamic (correct)script-src weaker, but XSS sources: none

The red herring that cost us the first fix

Our first attempt was to narrow the blast radius. Most pages on a marketing site do not need a per-request nonce, so we removed the headers() read from app/layout.tsx and rebuilt, expecting most of the 26 routes to go static.

The build output did not move. Still zero.

The read we removed was not the only one. components/seo/json-ld.tsx also sourced the nonce, and that component renders on every page in the app, because the root layout mounts it. We had deleted the obvious call and left the one that actually mattered, which produced the worst possible debugging signal: a change that is correct, and does nothing.

This is worth naming as a general pattern. When a fix that should work produces no change at all, the instinct is to assume the fix was wrong. Usually the fix was incomplete, and the thing you did not find is being pulled in by a layout, a provider, or a middleware that never appears in the file you are editing. Search for the API, not for the component you suspect.

Then we checked whether the goal was reachable at all

With both reads found, the real question was whether we could keep the nonce and still prerender most of the site. We could scope our own inline scripts. Could we get rid of inline script entirely?

No, and the reason is in the framework itself. Next.js streams its React Server Component payload to the browser through its own inline script tags:

<script>self.__next_f.push(...)</script>

Those appear in every document Next.js produces. Under a nonce-or-hash policy they need the nonce too, and they are the specific reason a nonce forces dynamic rendering in the first place. There is no arrangement of a Next.js App Router application that is both statically prerendered and free of inline script.

We spent an afternoon confirming this rather than assuming it, and that was the right call. The conclusion changed the decision from "optimize this" to "choose one," and those are very different conversations to have with a client.

We chose static, and here is the honest cost

script-src regains 'unsafe-inline'. That is a real weakening and we are not going to dress it up. One directive got worse.

What made it acceptable is that we went looking for the XSS sources this site actually has, and there are none. No user-generated content is rendered. No query parameter is reflected into markup. All MDX is repository-authored and reviewed in a pull request. json-ld.tsx escapes <, >, and & on top of that, so structured data cannot break out of its own tag even after the headless CMS migration our content layer is designed to allow.

Meanwhile every directive that actually contains an incident stayed strict:

object-src 'none'
base-uri 'self'
form-action 'self'
frame-ancestors 'none'
connect-src <closed allowlist>

That set is doing more work than people give it credit for. base-uri 'self' blocks <base> hijacking. form-action 'self' blocks credential exfiltration by retargeting a form. The closed connect-src blocks the exfiltration leg of most XSS payloads, which is the step that turns a script injection into an actual breach. An attacker who lands script on this page still cannot send what they find anywhere.

Our position, which is arguable and which we will defend: 'unsafe-inline' in script-src on a site with no injection sources is a smaller real risk than serving every page dynamically, and the checklist-driven instinct to treat any 'unsafe-inline' as an automatic failure is how teams end up with slow sites and no additional safety.

Verified, not assumed

The claim is that pages are now cached. Here is the evidence we took from a production build rather than from the build log:

x-nextjs-cache: HIT
Cache-Control: s-maxage=31536000

Prerendered routes went from 0 of 26 to 24 of 26. The two that stayed dynamic are /contact and /newsletter/confirm, and they are correct to be dynamic, because both read searchParams. A page that genuinely depends on the request should render per request. The bug was never that dynamic rendering exists, it was that we had accidentally applied it to a set of pages that are identical for every visitor.

The leftover we did not delete

proxy.ts is now inert. The obvious cleanup is to remove it, and we deliberately have not, because deleting it is not free: the old file still emits its own CSP header, and until it is removed carefully the site would send two conflicting policies and break hydration. It is documented as dead, with the reason, and it comes out in its own change where the effect can be observed on its own.

We also corrected a note in our internal audit doc that claimed the nonce build was "still CDN-cacheable." It was not. A stale internal note that reassures you about a problem you still have is worse than no note, and the correction was part of the same commit as the fix.

What we would tell another team

Read your build output. 0 of 26 routes prerendered was printed on every build for weeks and we treated it as noise, because the site was fast in development and nothing was visibly broken. Performance regressions do not announce themselves; they sit in a line of build output that everyone has learned to scroll past.

And when a security control and a performance property appear to conflict, find out whether the conflict is real before you start optimizing. Ours was real. The afternoon we spent proving that saved us from a much longer week of trying to configure our way out of a constraint that does not bend.