Partial Prerendering splits a page into a build-frozen static shell and per-request dynamic holes. Compiled binaries handle PPR origin-only out of the box: the shell flushes immediately (TTFB never waits on data) and the holes stream into the same response. Nothing on this page is required for PPR to work.
What this guide adds is the CDN half of Next’s PPR platform protocol: serving the shell from the edge (~10–30ms first paint, asset preloads starting immediately) while your origin renders only the holes. In a real deployment this took a warm dashboard hard-load from ~350ms of blank tab to a 23ms first byte.
Do you need this? Only for routes that mix a static frame with per-request content and whose first paint matters. Fully static pages get the same edge TTFB from a plain CDN cache rule with zero moving parts. The holes render at origin speed either way.
How it fits together
-
The resume protocol (built in, always on). A
POSTto a PPR route with thenext-resume: 1header and the route’spostponedStateas the body renders only the deferred holes. The regression suite pins this. -
The shell endpoint (opt-in,
NBC_PPR_SHELL). The binary serves each PPR route’s build-frozen artifacts:GET /_nbc/ppr-shell/<route> → { shell, postponed, buildId, tags }Pairs are discovered at boot from the extracted tree and served from memory as exact routes — request data never touches the filesystem. All three artifacts come from a prerender — at build time, or a later regeneration — never from the context of a visitor’s request, so they cannot contain per-user data.
-
An edge worker (yours, ~100 lines). Caches the shell, streams it instantly, resumes the holes from your origin, stitches one response. A complete self-populating Cloudflare implementation ships in
examples/cloudflare-ppr-worker.
Enabling the endpoint
NBC_PPR_SHELL=1 ./dist/app # open mode
NBC_PPR_SHELL=some-shared-token ./dist/app # token mode- Open mode responses are
Cache-Control: public, max-age=3600— build-time content that shared caches may hold. - Token mode requires the value in an
x-nbc-shell-tokenheader (401 otherwise) and respondsprivate, no-store. Use token mode for auth-gated routes so their skeletons aren’t publicly enumerable — and note the header is load-bearing: behind a zone-wide “cache everything” CDN rule, a publicly-cacheable tokened response would be cached once and served to anyone, silently defeating the token. - Routes without a PPR pair (fully static, or fully dynamic) return 404. Unset the variable and the endpoint doesn’t exist.
The Cloudflare worker
The example worker needs no KV, no build-time push pipeline, and no CI changes:
- Cache miss → the visitor passes through to the origin while the
shell warms into
caches.defaultin the background. No request is ever slower than having no worker. - Cache hit → the shell streams from the edge; in parallel a
resume
POSTrenders the holes at the origin and pipes onto the same response. - Resume failure (origin down, build skew) → the cached shell is evicted and that visitor keeps the shell’s Suspense fallbacks — degraded, never mixed builds.
Deploy once, ever:
cd examples/cloudflare-ppr-worker
# set your route pattern(s) in wrangler.toml
wrangler secret put SHELL_TOKEN # token mode only
wrangler deployOne deployment serves every app in your account — the shell cache key derives from each request’s hostname, so apps never mix. Attach route patterns narrowly (only PPR paths): everything else should never invoke the worker.
Staleness
- Deploys: a purge-on-deploy job (zone
purge_everything) clears cached shells with everything else; the endpoint’smax-ageis the backstop. - Runtime revalidation: the endpoint entry is never dropped. It reads
the route’s pair back through Next’s own incremental cache — the same
source Next serves from — and falls back to the prerender on disk. A
shell that regenerates (an ISR expiry, or a
use cacheregion inside it whose tag was revalidated) therefore follows the origin wherever the configured handler put it: on disk, in a memory tier, or in the shared store a customcacheHandlerwrites — including on a pod that only ever reads what a different pod regenerated. A shell that never regenerates (a static frame around dynamic holes) keeps its build-frozen pair: the postponed state is code-shaped, only a new build changes it, and that is a new process which re-reads at boot. - Before the first render there is nothing to read back. An endpoint request that arrives before the binary has booted Next serves the build output; it self-corrects as soon as the process handles a request.
- The edge copy converges on its own. An origin cannot evict a CDN’s
copy, so the example worker revalidates instead: on a warm hit older
than a minute it re-checks the endpoint in the background with
If-None-Match. An unchanged shell answers304with no body; a regenerated one replaces the cached copy. The edge therefore follows a regeneration within about a minute instead of sitting stale for the rest of its hour, at the cost of one bodiless request per PoP per minute. The endpoint also emitsCache-Tag— Next’s own tag set for the route — so a tag-aware CDN (Fastly surrogate keys, Cloudflare Enterprise cache tags) can purge the shell outright rather than wait for the next check.
Hard-won details
The example worker already handles these; they’re documented so you don’t rediscover them in an integration of your own:
- Every pass-through must bypass the zone cache
(
fetch(req, { cache: "no-store" })). A worker subrequest is not automatically the no-worker behavior: it transits Cloudflare’s cache, which keys on URL alone and ignoresVary— and Next serves a PPR route’s segment-prefetch payloads withs-maxage=31536000at the same URL as its document. Without the bypass, one passed-through prefetch caches flight data under the document’s URL and every document load on that route returns raw RSC until the zone is purged. The symptom is a page rendering astext/x-componentprotocol text right after a deploy (the purge empties the cache and the refill race begins). (orapplication/octet-stream). Workers’fetch()stamps string bodiestext/plain, and Next rejects that with 405 before its resume branch — the symptom is an intermittent stuck-on-fallbacks page as the worker’s cache self-evicts and re-warms. The example sendsapplication/x-www-form-urlencoded, the no-JS form-post path wherenext-resumeis honored. - Never key the cache on a URL you also fetch. A cache key is an
identifier, not a fetch target, and
caches.defaultis the zone cache — so keying on/_nbc/ppr-shell/<route>inherits whatever cacheability that URL has picked up, including from the worker’s owncache: "no-store"shell fetch. Telling Cloudflare never to cache a URL and then asking it to cache that URL loses:put()resolves normally and stores nothing, so the shell re-warms on every request and is never served, with no error raised anywhere. The example keys on a synthetic/__nbc-shell/<route>instead, judged only on the response it is handed. A declinedput()does not throw, so reading the key straight back is the only way to tell a real write from a discarded one — worth doing once when bringing up an integration. - A freshness check must bypass the zone cache too. In open mode the
endpoint’s own response is
public, max-age=3600, so a worker subrequest that transits the zone can be answered with the very copy you are trying to replace — the check validates against itself and never sees the new shell. The example fetches the endpoint withcache: "no-store"for exactly this reason. - Forward the original request headers on the resume POST — the
holes are per-user and the origin needs cookies to render them. They
flow only worker → origin (the path they already travel); nothing
user-specific is ever cached, and the combined response is
private, no-store. - Only document GETs take the shell path. RSC/prefetch requests,
draft mode, POSTs, and non-HTML
Acceptheaders pass through — the client router’s own requests never hit the worker’s logic. - Auth-gated routes change redirect UX. The worker serves the (public, build-time) shell before your auth can 302, so logged-out visitors get the shell and are redirected client-side after hydration instead of instantly. Decide per route whether that’s acceptable.
- Edge shells only apply to document loads — first visits, reloads, direct links. Client-side navigations fetch RSC payloads and never make document requests.
Verifying
# token gate
curl -s -o /dev/null -w '%{http_code}\n' https://your-app.com/_nbc/ppr-shell/route # 401
# warm document: edge shell + streamed holes
curl -s -D- -o /dev/null -H 'Accept: text/html' https://your-app.com/route \
| grep x-ppr-shell-buildIn the browser: hard-reload the route twice with DevTools open. The
warm load’s document shows x-ppr-shell-build, first byte in tens of
milliseconds, and the full response streaming in behind it. For live
worker telemetry, wrangler tail — the example logs only failed
resumes.