
Next.js Implementing Partial Prerendering on your platform
Most of the articles in this series are written for people building Next.js applications. This one isn't. If you're building a deployment platform, a hosting integration, or a custom adapter for Next.js — rather than a website with it — Partial Prerendering (PPR) is the feature whose implementation details actually matter to you directly, because it's the one App Router capability that requires your infrastructure to understand and cooperate with a specific request/response protocol, rather than just running Next.js as an opaque server process.
This article walks through what PPR produces at build time, what has to be stored where, and three implementation tiers — from "do nothing special and it still works" up to "shave the static shell's latency down to edge speed."
What PPR actually produces
Partial Prerendering combines static and dynamic rendering within a single route — a page can have a fully static header and navigation, streaming in a personalized recommendations section, all from one route definition. At build time, for every PPR-enabled route, Next.js generates three artifacts:
A static HTML shell — everything prerenderable, with Suspense fallbacks marking where dynamic content will eventually appear. An RSC payload for those same static portions. And a postponedState value — a serialized string that should be treated as entirely opaque. This is worth stating plainly because it's the single most important constraint in this whole guide: don't parse it, don't modify it, don't attempt to inspect its structure. Altering it in any way produces incorrect dynamic rendering output, silently — there's no validation step that catches a corrupted postponed state and tells you something's wrong.
At request time, three things happen in sequence: the server sends the static shell immediately, it resumes rendering the dynamic portions using the postponed state, and that dynamic content streams to the client, letting React hydrate the deferred Suspense boundaries as data arrives. The user's experience is a static shell appearing instantly, with the personalized or uncached parts filling in progressively.
Storage: the shell and the postponed state are one unit, not two
Both artifacts — shell and postponedState — belong to a single PPR route, and they must be stored and updated atomically. This is the constraint that will bite you hardest if you miss it: when a route revalidates, whether through time-based ISR or on-demand revalidateTag, Next.js regenerates both pieces together as a matched pair. Serving a new shell alongside an old postponed state (or the reverse) produces incorrect dynamic content — not a crash, not an obvious error, just wrong output that renders successfully.
If you're implementing a custom adapter, requestMeta.onCacheEntryV2 is the hook for observing cache updates as they happen and propagating both pieces to your storage backend together, in the same write. Treat this pairing as a hard invariant of your storage layer, not a "usually true" convention — anything that could let the two drift out of sync (separate cache entries with independent TTLs, say) is a bug waiting to surface as a confusing, hard-to-reproduce content mismatch.
Tier one: origin-only
This is the baseline, and it requires nothing beyond a platform that can stream an HTTP response. All requests hit the Next.js server directly. It reads the shell from its own local cache, sends it, then renders and streams the dynamic content — this is precisely what next start does with zero configuration.
If your platform can stream an HTTP response at all, it already supports PPR at this tier. There's no additional infrastructure, no protocol implementation, nothing platform-specific required. This is worth stating because it's easy to assume PPR requires CDN-level sophistication to work at all — it doesn't. The more advanced tiers below are strictly about improving time-to-first-byte on top of behavior that already works correctly at this baseline.
Tier two: CDN shell + origin compute
For a meaningfully faster TTFB, the static shell can live at the CDN edge instead of only at origin. The request flow becomes:
- The CDN serves the cached shell immediately — edge latency, not a round trip to origin.
- In parallel with streaming that shell, the CDN sends a resume request to the origin.
- The origin renders only the dynamic portions and streams them back.
- The CDN concatenates shell and dynamic content into a single streaming response to the client.
This requires your CDN to support combining cached and freshly-streamed content into one coherent streaming response — not every CDN can do this out of the box, and it's the main technical lift of this tier. Done correctly, the static shell's TTFB drops to edge latency while the dynamic portion still streams from wherever your origin actually lives.
For the absolute lowest latency achievable, the shell doesn't even need to come from a CDN cache — it can be served from edge storage (a KV store populated during your adapter's onBuildComplete hook, for instance). This is purely a platform architecture decision on your end; it requires no changes to the Next.js application whatsoever, since from the app's perspective, the shell is just being fetched from a different backing store than a CDN's usual cache.
The resume protocol
This is the actual wire protocol that makes tier two (and adapter-based deployments generally) work: a mechanism for telling the Next.js handler "skip generating the shell, just render the deferred parts." In plain next start, this never needs to be invoked explicitly — the server handles shell and dynamic render together, automatically, in one pass. It only matters once you're the one serving the shell separately and need to ask the Next.js process to fill in the rest.
CDN-to-origin
When your CDN makes an HTTP request to a Next.js origin to resume rendering:
- Send a POST request to the route.
- Include the header
next-resume: 1. - Put the
postponedStateblob directly in the request body.
The server responds by rendering only the deferred Suspense boundaries and streaming that result back.
One wrinkle worth knowing about: when a POST request is simultaneously a Server Action invocation and a PPR resume, the request body contains the postponed state followed by the action's own body, concatenated. The x-next-resume-state-length header carries the byte length of the postponed-state prefix specifically so your handler implementation can correctly split the two apart. For the ordinary case — a pure PPR resume, no Server Action involved — the entire body is just the postponed state, and this header isn't present at all.
Adapter-based (in-process, no HTTP round trip)
If your platform invokes the Next.js handler function directly rather than over HTTP, the equivalent is: call the entrypoint with req.method set to 'POST', the next-resume: 1 header present, and the postponed state as the request body — or, more directly, pass requestMeta: { postponed: postponedState } as the third argument to the handler invocation, which achieves the same result while entirely bypassing the HTTP layer. The handler renders the deferred boundaries and streams straight to your response object, in-process.
Locating PPR routes in your build output
Your adapter's build output identifies PPR routes for you: look for renderingMode: 'PARTIALLY_STATIC' in the prerenders array. Iterate outputs.prerenders, find entries matching that rendering mode, and read fallback.postponedState off each one to get the artifact you'll need to store. The headers needed for the resume protocol itself are conveniently pre-packaged too — pprChain.headers gives you exactly { 'next-resume': '1' }, ready to attach to whatever request you construct.
For the full adapter API surface with worked code examples beyond what's summarized here, the dedicated "Implementing PPR in an Adapter" reference is the deeper follow-up to this guide.
The implementation checklist
Pulling the above into an actual sequence of work, in order:
1. Read PPR outputs at build time. In your adapter's onBuildComplete, scan for prerenders with renderingMode: 'PARTIALLY_STATIC'. Store the shell HTML and postponedState for each, into your cache.
2. Serve the shell at request time. For incoming requests matching a PPR route, serve the cached shell immediately and begin streaming to the client without waiting on anything else.
3. Resume dynamic rendering. CDN-to-origin: POST to the handler with next-resume: 1 and the postponed state as body. Adapter-based: invoke the handler directly with the POST method and postponed state (or the requestMeta.postponed shortcut). Stream what comes back to the client.
4. Handle cache updates as a unit. requestMeta.onCacheEntryV2 is your signal that a new shell + postponed-state pair exists after revalidation — write both, atomically, to your storage backend.
5. Build in graceful degradation. If the postponed state is missing or you suspect it's stale, fall back to a full server render rather than attempting to resume with data you're not confident in. The user gets a complete, correct page — just without the shell-first speed advantage for that one request. This fallback path matters more than it might seem: a platform that silently serves a broken partial render because storage briefly desynced is a worse failure mode than one that occasionally falls back to a slower, but always-correct, full render.
Key Takeaways
| Tier | What it requires | What it buys you |
|---|---|---|
| Origin-only | Streaming HTTP support — nothing else | Full PPR correctness, origin-speed shell TTFB |
| CDN shell + origin compute | CDN that can concatenate cached + streamed content | Edge-speed shell TTFB, dynamic content still from origin |
| Shell from edge storage | A KV-style store populated at build time | Lowest possible shell latency, no app-side changes needed |
| Artifact | Must be stored | Storage rule |
|---|---|---|
| Static HTML shell | Yes | Paired atomically with postponed state |
postponedState | Yes, treated as opaque | Never parsed or modified |
| RSC payload | Yes (static portions) | — |
The core discipline this guide is really asking for is narrow but strict: treat the shell and postponed state as one atomic unit, never touch the postponed state's contents, and implement the resume protocol exactly as specified rather than approximating it. Get those three things right and PPR support on your platform is a genuinely tractable engineering project — the origin-only tier already works today with zero extra code, and each tier above it is a pure latency optimization layered on top of behavior that was already correct.


