Skip to content

Serving shells from a CDN

Putting build-time shells on the edge, and what rsc-kit does not do.

Updated View as Markdown

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-revalidate

public 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-store

because 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 visitor

There 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 ms

Before 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.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close