A prerendered page is a file. So is a PPR shell. Both can sit on a CDN and be served without touching your origin.
What the build gives you
build/static/
about.html a whole page, frozen
posts/hello.html likewise
posts/[slug].ppr.html a shell — static parts frozen, holes still empty
about.flight the payload for a client-side navigation
about.seg1.flight the same, for a client already holding one layout.html is a finished page. .ppr.html is a shell: the layout, nav and
everything outside a <Suspense> boundary, with the fallbacks still in place.
Neither contains per-visitor data — they were rendered at build time, in no
request’s context, which is what makes them safe for a shared cache.
Caching them
The host already sends the right header:
Cache-Control: public, max-age=0, must-revalidatepublic says a shared cache may store it. max-age=0, must-revalidate says
check with the origin first. So a CDN in front of this holds the bytes but
still asks every time — which saves bandwidth and nothing else.
To actually serve from the edge, add a cache rule for the paths you want held, and give the CDN an edge TTL. On Cloudflare that is a Cache Rule with Edge TTL → Override origin.
Do not blanket-cache the whole site. A route with middleware is deliberately sent as:
Cache-Control: private, no-storebecause middleware runs per visitor — that is a page whose content depends on who asked. A zone-wide “cache everything” rule overrides that and serves one person’s gated page to everyone. Scope the rule to the paths you know are static.
Deployments are handled for you: cached responses carry a build version, so a new deploy does not leave old shells being served against a new payload.
Finishing a shell at the edge
A shell has holes in it, and something has to fill them. That happens at your origin, into the same response — the shell is written first, then the boundaries it could not finish.
You get this with no configuration: request a PPR route and the document you receive already contains its dynamic content.
A small inline script from React moves each hole into place as the HTML parses. So the content appears without waiting for the app bundle or for hydration — on a slow connection, the difference between a spinner and a page. It also means the content is in the HTML a crawler reads.
This is not the same as working without JavaScript: with scripting off, the fallbacks stay.
To serve the shell itself from a CDN, two endpoints exist for an edge worker:
GET /_rsc/ppr-shell?url=/dashboard the build-time shell, cacheable
POST /_rsc/ppr-resume?url=/dashboard the holes, for this visitorThere is a complete Cloudflare implementation in
examples/cloudflare-ppr-worker,
with no KV and no build step. The cache fills itself from the shell endpoint: a
miss goes to the origin while the shell warms behind it, and a hit streams the
shell then pipes the resumed holes onto the same response.
What the response looks like on the wire
Measured on a deployed worker, for a page whose hole takes 2.5 s:
headers 55 ms
first body byte 56 ms
shell heading 56 ms ← the page is on screen here
fallback markup 56 ms
hole content 2549 ms ← same response, no second request
stream complete 2549 msBefore this, the same document finished in 2 ms and contained no hole at all — the content arrived later, on a separate payload fetch, after React had hydrated.
The trade is that the response stays open until the holes finish. Previously
the document closed immediately and load fired early; now it fires when the
slowest boundary resolves. Nothing a visitor sees is slower — the shell paints
at the same moment either way — but page-level metrics that key on load will
read differently, and a proxy with a short response timeout needs to allow for
the whole render rather than just the shell.
Guarded routes are never cached
The shell endpoint answers 404 for any route that declares middleware. Such a
page is not cacheable by a shared cache at all, so it never becomes a cache
entry — refused at the source rather than checked at the edge.
The resume endpoint runs that route’s middleware against the caller’s own cookies, and refuses before rendering anything. An edge worker must therefore forward the visitor’s request rather than making one of its own; a resume asked for with no cookies is an anonymous visitor and gets an anonymous answer.
What stays on your origin
Next’s PPR protocol hands the postponed blob to the CDN and takes it back on the resume, which means the resume endpoint parses something an attacker can write. That is the shape of a known denial-of-service against it.
Here the endpoint takes a url. The origin reads its own state from disk, and a body posted to it is ignored. This is only possible because — unlike a generic CDN — the origin already has the artifact, so there is nothing to hand out and take back.
When a CDN owns the response
Everything above rests on one fact: on this host, a per-visitor response head
can only come from middleware. responseHeaders() and cookies().set() throw
outside it, so a route that declares no middleware has no way to acquire one —
which is why “declares middleware” is a safe answer to “is this cacheable”.
An auth proxy in front of the app breaks that, and quietly. One with sliding
expiry re-issues the session on an ordinary 200, so the response leaving your
CDN carries a Set-Cookie this host never sent. Marked public, that is one
visitor’s session handed to the next.
Give any path your proxy covers a middleware.ts, even an empty one. That
is what makes a route covered here, and covered routes are excluded from every
cache decision: private, no-store on the page, and refused outright by the
shell endpoint.
Two things worth knowing about this failure if you go looking for it.
You cannot detect this by inspecting the response. Nothing added a
Vary: Cookie, and looking for Set-Cookie only catches the cookie version —
an x-user-id, a CSRF token or a locale header leaks the same way.
The reliable question is structural: who owns the response head on this route?
It fails both ways. The loud one is a session leaking to the next visitor. The quiet one is the reverse — an anonymous response cached first, then served to someone who should have had a session, silently signing them out. The first gets reported; the second looks like a flaky login.