---
title: "Serving shells from a CDN"
description: "Putting build-time shells on the edge, and what rsc-kit does not do."
---

> Documentation Index
> Fetch the complete documentation index at: https://docs.rsc-kit.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# Serving shells from a CDN

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`](https://github.com/rsc-kit/rsc-kit/tree/main/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.

Source: https://docs.rsc-kit.dev/guides/edge-caching/index.mdx
