Engineering noteResearch & Engineering
Part of: React Router 8
One Nonce, Two Inputs: Fixing React Router Streaming
We restored loader data with one nonce input. For late Suspense content, we needed the same nonce in another place.
The programme
On this page
- Two writers, one document
- Input one: the ServerRouter nonce
- Input two: the renderToReadableStream option
- The entry file as it ran on 15 October 2026
- Why development never showed it
- The staging browser check
- What we did not measure
- When this pattern is the wrong choice
- Verify this
- Appendix: versions
- Frequently asked questions
With a nonce-based CSP, we restored loader data for hydration by passing a nonce to React Router. We saw the volunteer home page pass a manual check, yet on the learner home on staging we still saw client rendering with React error #419. There, we had content waiting behind a Suspense boundary after the shell had gone out. To finish that boundary on the server, we also needed the same nonce in React’s render options. We had two rendering inputs for inline scripts in one response. We had fixed one and missed the other.
Both pages belong to the platform behind TEEI Language and TEEI Mentorship: one React Router 8 application on Cloudflare Workers. The lead article in this series covers how one codebase serves both programmes, and how TEEI runs its platforms on Cloudflare Workers covers the platform underneath.
Built through volunteer contributions. TEEI’s software platforms are built through volunteer work, with NOK 0 spent on paid software development. Infrastructure and third-party services are separate from that figure.
Two writers, one document
A nonce-based policy is simple to state. For each response we make a fresh random value, name it in the Content-Security-Policy header, and write the same value into the nonce attribute of every inline <script> we mean to run. Scripts whose nonce matches run; the rest are refused (MDN on CSP).
We stream our pages: in the server entry, we return a Response when the shell is ready, without waiting for the complete document, and keep writing into it while slower data arrives. In a React Router framework app on Workers, the inline scripts in that stream come from two libraries, inside the same HTML document:
- From React Router come
window.__reactRouterContext.streamController.enqueue(...)scripts and a finalstreamController.close(). In the source we find an enqueue script for each non-empty decoded chunk of the data stream, not for each loader result, and a close script at the end. This is how loader data reaches hydration. - From React come small scripts of its own when a
<Suspense>boundary resolves after the shell has gone out. With them, the streamed content is moved from a hidden element into place.
In our root component we also put the nonce on our own inline scripts and on <Scripts> and <ScrollRestoration>. That third channel reaches neither of the two above: those scripts are written by the framework and by React, not by our markup.
In React Router’s security guide, read on 15 October 2026, we find both inputs in entry.server.tsx: the nonce prop on <ServerRouter> and the nonce option of renderToReadableStream. By passing the nonce to one, we do not supply it to the other.
Input one: the ServerRouter nonce
On the evening of 13 July 2026 we turned on the enforcing policy in a change that did not touch entry.server.tsx. In a production build, we then saw hydration get no loader data on any route; in the words of the commit message that fixed it, “client hydration got no loader data on EVERY route in a production build.”
The fix was one prop. In React Router’s source, we see the data-transfer scripts written by its StreamTransfer component, each with whatever nonce ServerRouter received. This is the library code from react-router 8.4.0, as built for production:
Verbatim excerpt.
let scriptTag = value ? /* @__PURE__ */ React$1.createElement("script", {
nonce,
dangerouslySetInnerHTML: { __html: `window.__reactRouterContext.streamController.enqueue(${escapeHtml(JSON.stringify(value))});` }
}) : null;
if (done) return /* @__PURE__ */ React$1.createElement(React$1.Fragment, null, scriptTag, /* @__PURE__ */ React$1.createElement("script", {
nonce,
dangerouslySetInnerHTML: { __html: `window.__reactRouterContext.streamController.close();` }
}));
With no nonce on ServerRouter, nonce is undefined and neither transfer script gets the attribute, so both are refused under an enforcing policy. Before constructing the client router, we suspend until the initial decoded state arrives, without waiting for deferred promises to settle. Visible server-rendered HTML does not establish that the client router has initialised.
In the same element we find JSON.stringify followed by Unicode escaping of &, >, <, U+2028 and U+2029. With escaping we keep the serialised value inside the script; with the nonce we permit execution.
By setting the prop, we also supply the default nonce for <Links>, <Scripts> and <ScrollRestoration>, unless we give one of them its own (React Router, ServerRouter). We do not supply React’s own scripts with it.
Input two: the renderToReadableStream option
The second failure appeared only on pages with a streamed boundary. In the loader of our learner home page, we return some of its content, a set of teasers, as an unawaited promise and render it through <Await>, the pattern in React Router’s streaming guide. We start the teaser promise before the loader’s other awaited work, so we may begin rendering with the teasers already available. We use React’s reveal path only if the promise is still pending after we flush the shell: the teasers then follow through the open stream, and we reveal them with React’s inline scripts. We supply the nonce for those scripts through the options of renderToReadableStream; the nonce on ServerRouter does not reach them.
That evening, we caught it on the learner home with our automated staging check: React’s reveal script was refused, the boundary could not be finished from the server, and the page fell back to client rendering with Minified React error #419. We had checked the volunteer home page by hand and seen it pass, because it had no streamed boundary in that incident. Both pages had the nonce on ServerRouter; only one of them needed the second input.
We can show React’s side of this without React Router or a network. In this script we hold one Suspense boundary back with a 50 ms timer, so it resolves after the shell has been written; we render the page once without a nonce and once with one, and count the inline scripts in the output:
Adapted for publication.
import React from 'react';
import { renderToReadableStream } from 'react-dom/server.edge';
const h = React.createElement;
let late;
function Late() {
// Ready only after the shell has been sent, like an unawaited loader value behind <Await>.
if (!late) late = new Promise((r) => setTimeout(() => { late.done = true; r(); }, 50));
if (!late.done) throw late;
return h('p', null, 'streamed content');
}
const App = () => h('html', null, h('body', null,
h('main', null, 'shell'),
h(React.Suspense, { fallback: h('span', null, 'loading') }, h(Late))));
async function run(opts) {
late = undefined;
const stream = await renderToReadableStream(h(App), opts);
return new Response(stream).text();
}
function report(label, html) {
const scripts = [...html.matchAll(/<script\b([^>]*)>/g)].map((m) => m[1].trim());
const nonced = scripts.filter((a) => /nonce=/.test(a)).length;
console.log(`${label}: ${scripts.length} inline <script>, ${nonced} with a nonce`);
}
console.log('react', React.version);
report('without nonce', await run({}));
report('with nonce', await run({ nonce: 'TEST' }));
We ran it with react and react-dom 19.3.0 on Node 22 on 15 October 2026, and recorded this output:
Verbatim excerpt.
react 19.3.0
without nonce: 2 inline <script>, 0 with a nonce
with nonce: 2 inline <script>, 2 with a nonce
Without the option, we find the streamed paragraph in a hidden <div> and the scripts meant to move it into place without a nonce, so under an enforcing policy they would never run. With it, both carry one. TEST is a fixed value for counting attributes in this test only; a response always needs a fresh random nonce. Here we inspect emitted HTML only; execution under an enforcing policy is a separate browser check, described below. We measured two scripts for this version and this one boundary; other versions, or pages with several boundaries, can produce a different number.
We cannot treat the two inputs as independent either. In React Router 8.4.0, we find StreamTransfer itself rendered inside Suspense, with each continuation inside another Suspense boundary, so later data-transfer scripts may also depend on React’s reveal scripts. We derived that dependency from the source; we have not reproduced it. Set both, always.
The entry file as it ran on 15 October 2026
This is the shape of our entry.server.tsx as we read it on 15 October 2026, when the production release carried the same file as our source tree. We renamed the function that reads the request’s nonce and shortened the comments and the error reporting; the two inputs and the development gate are as they ran in production that day.
Adapted for publication.
// One nonce per request, for BOTH producers of inline scripts.
// Production only: see "Why development never showed it".
const cspNonce = import.meta.env.DEV ? undefined : getRequestNonce(loadContext) || undefined;
const nonceProp = cspNonce ? { nonce: cspNonce } : {};
const body = await renderToReadableStream(
<ServerRouter context={routerContext} url={request.url} {...nonceProp} />,
{
...nonceProp,
signal: request.signal,
onError(error: unknown) {
responseStatusCode = 500;
// report the error
},
},
);
responseHeaders.set('Content-Type', 'text/html');
return new Response(body, { headers: responseHeaders, status: responseStatusCode });
We create the nonce in the Worker once per request, before routing, and after the render we write the same value into the Content-Security-Policy header of that response. With || undefined, we turn an empty string into an absent attribute, because nonce="" matches nothing in a policy. We await the shell, not body.allReady, and return the stream from the server entry: on Workers the headers, with the nonce, leave before the scripts that depend on them. For the status, we infer from the code: when we construct the Response, we copy the status; a later onError can change responseStatusCode, but cannot update that response.
On our public login pages in production, fetched with curl on 15 October 2026 at 10:48 UTC, every script in the HTML carried the same nonce as the response header, and five requests to the same page returned five different values. That is a reading of HTML and headers, not a browser enforcing the policy, and it shows nonce propagation only: the streamed boundary we know of is behind sign-in.
Why development never showed it
On 13 July our end-to-end tests ran against the development server, and the development server does not enforce the nonce. Both failures existed only in a production build behind the enforcing policy, so we could not see them in that suite. The commit message for the first fix says it plainly: the enforcing policy “was never exercised.”
The obvious repair, passing the nonce everywhere, broke development instead. With a development nonce on ServerRouter, we saw a hydration mismatch on the development-only critical CSS <script>: the server output had nonce="" and the client expected none. Eighteen specs in one menu suite went red. The fix was to pass the ServerRouter nonce only in production builds (import.meta.env.DEV ? undefined : ...); the render option we added later that evening uses the same gate. Our own scripts in the root component still receive the nonce in development.
That gate is a trade: development and production now differ on purpose exactly where this bug lives, so only a production build can catch a missing input. If you take the gate, put a check against a production build behind the enforcing policy somewhere that blocks a release.
The staging browser check
We have no unit test that requires both inputs; we searched for one on 15 October 2026 and found none. In the staging verification script, we load a production-mode candidate in a real browser, signed out and signed in; signed in, we load both the learner home and the volunteer home.
Since 30 July 2026 we have attached both pageerror and console listeners before navigation: in Chromium, we see CSP refusals as console errors, without an uncaught exception. We check hydration and CSP patterns before known noise, so a hydration or CSP message that names a known-noise script keeps its classification. In that script, we attest a release only after the whole run passes.
What we did not measure
We did not measure how many requests or people met either failure; we caught both on staging. We find all three changes from that evening, the two inputs and the development gate, in the first logged production deploy after the policy change. We cannot rule out an intermediate production version: we also find a previous version named without its own log entry.
In the staging check, we observe what a production build does; we do not force the boundary to resolve after the shell. The check with which we caught error #419 in July is older than the console rule, and we did not record which part of it saw the error. Since 24 August 2026 we have run our end-to-end suite against a production-built Worker started locally, not the development server. We have not checked policy enforcement or detection of a missing nonce input in that run. We therefore do not count it as a guard.
When this pattern is the wrong choice
Prerendered or cached HTML. A per-response nonce cannot be baked into a page that is served more than once, and React has no nonce option for prerendering. For statically prerendered pages, we find this recommendation in the React Router guide: “prefer CSP hashes or external scripts instead of a per-response nonce” (React Router, React Server Components). If your HTML is cached at the edge and shared between visitors, this pattern does not apply as it stands.
Verify this
For React’s side, save the reproduction above as an .mjs file in a project with react and react-dom 19.3.0 and run it with Node.
For React Router, use your own test app:
- Return an unawaited loader promise through
<Await>and keep it pending until after the shell is flushed. Send a policy that permits scripts only with your render nonce, and build for production with both inputs. - Confirm that the response contains data-transfer and reveal scripts. For data transfer we need a server handoff stream in the router context and a rendered
<Scripts>. - In the browser, confirm that the late content becomes visible, the page hydrates and no CSP refusal appears. HTML alone is insufficient: the content may still be in a hidden segment.
- Remove only the
ServerRouternonce and watch for “Refused to execute inline script”. Restore it, then remove only the render option and repeat. Check whether late content becomes visible; in our staging incident this case ended in error #419 and client rendering. For browser assertions, read the.nonceproperty;getAttribute('nonce')returns an empty string.
If you are building on React Router and Cloudflare Workers, talk to us and we will walk you through the code.
Appendix: versions
| Package | Version |
|---|---|
react-router, @react-router/cloudflare, @react-router/dev | 8.4.0 |
react, react-dom | 19.3.0 |
vite | 8.1.4 |
wrangler | 4.144.0 |
The entry file in production on 15 October 2026 is the same as the one in our source tree that day.
Frequently asked questions
Where does the CSP nonce go in a React Router framework app?
In entry.server.tsx, in two places: the nonce prop on ServerRouter and the nonce option of renderToReadableStream. We use the same value for both, fresh for every response, and send it in the Content-Security-Policy header of that response.
What causes Minified React error #419 under a Content Security Policy?
When a Suspense boundary resolves after the shell has gone out, the streamed content is moved into place by inline scripts from React, and we can give those scripts a nonce only through the renderToReadableStream options. In our case, we saw those scripts blocked under the policy; the boundary could not be finished from the server, and we saw client rendering with error #419.
Can I use a per-response nonce with prerendered or cached HTML?
Not by serving the same nonce-bearing HTML unchanged across responses. A nonce must be unique per response, so we cannot bake one into reusable HTML. For statically prerendered pages, we can use CSP hashes or external scripts, as described in the React Router guidance.