shell
Partial prerendering: cache the part of a page before its Suspense boundaries and stream each visitor's holes behind it.
app/store/page.tsx
export const revalidate = 60;
export const shell = 'cache';Reference
| Option | Type | Default | Description |
|---|---|---|---|
shell | 'cache' | (not set) | Turns on partial prerendering (PPR) for the page. Needs revalidate above 0. Any other value is ignored. |
Behavior
- First request (
X-Gio-Cache: ppr; shell=stored): the page renders in full and streams. The worker marks where React's shell ends - everything up to the pending Suspense boundaries - and the Rust server stores those bytes, up to 4 MB. - Later requests (
ppr; shell=hit): the stored shell is sent at once, then a holes-only render runs for this visitor -getServerSidePropsincluded, with their cookies - and its Suspense content and hydration data stream into the same response. - Stale shells (
ppr; shell=stale; age=...; revalidating) follow stale-while-revalidate like any cached page, and purges remove them. - Every PPR response is
Cache-Control: private, no-cache: its holes are personal. - If the holes render times out or the worker connection fails, the body ends after the shell and the Suspense fallbacks stay on screen. If
getServerSidePropsanswers this visitor with a redirect, a404or an error page instead, the page goes there itself (see Good to know).
The contract
The shell must be the same, byte for byte, for every visitor: only Suspense content may depend on who is asking. On a hit the shell and the holes come from different renders, and React places the holes by their position in the tree.
getServerSidePropsmay read cookies on a PPR page - that is the point. Render what comes from them only inside a Suspense boundary that suspends (withuse()on a per-request promise, for example).- Before the shell is stored, the worker checks it. If
getServerSidePropsread credentials and the shell holds rendered Suspense content (a boundary that did not suspend, or resolved before the shell was sent) or no pending boundary at all, the shell is not stored, the page still streams, and a warning names the route. - Metadata is part of the shell (it is in the
<head>). AgenerateMetadatathat reads credentials, or uses the props of agetServerSidePropsthat did, keeps the shell from being stored. - Cookies cannot be set from a holes render: the stored shell already sent the headers. They are dropped with a warning.
When it falls back
The page renders without PPR - as an ordinary page, under the usual caching rules - when:
- the render is not shareable: no
revalidate(or0), orgetServerSidePropsreturned response headers. The worker logsshell='cache' requires a shareable render (revalidate set, no per-request headers) - falling back; - a Node plugin with an
onResponsehook is installed (it needs the whole body); - the request is not a
GET(aHEAD, or a page action's re-render).
Under gio export nothing streams, so the page is exported in full.
Examples
A shared page with a personal part
app/store/page.tsx
import React, { Suspense, use } from 'react';
import type { GetServerSideProps, InferPageProps } from '@gio.js/core';
export const revalidate = 60;
export const shell = 'cache';
export const getServerSideProps: GetServerSideProps<{ who: string }> = async (ctx) => ({
props: { who: ctx.cookies['who'] ?? 'guest' }, // reruns for every visitor
});
// Runs on the server for the holes, and again in the browser while the page
// hydrates: keep it browser-safe (a fetch, not a database import).
async function greetingFor(who: string): Promise<string> {
const res = await fetch(`${process.env.GIO_PUBLIC_API_URL}/greeting?who=${encodeURIComponent(who)}`);
const { text } = (await res.json()) as { text: string };
return text;
}
function Greeting({ text }: { text: Promise<string> }) {
return <p>{use(text)}</p>; // suspends: stays out of the shell
}
export default function Store({ who }: InferPageProps<typeof getServerSideProps>) {
return (
<main>
<h1>Store</h1>{/* the shell: the same for everyone */}
<Suspense fallback={<p>Loading...</p>}>
<Greeting text={greetingFor(who)} />
</Suspense>
</main>
);
}Check it with GET requests: a HEAD request (curl -I) never takes the PPR path.
bash
curl -s -o /dev/null -D - http://localhost:3000/store | grep -i x-gio-cache # ppr; shell=stored
curl -s -o /dev/null -D - http://localhost:3000/store | grep -i x-gio-cache # ppr; shell=hitGood to know
- A
loading.tsxis a Suspense boundary around its folder: on a PPR page whose content suspends, the stored shell ends there. - A per-visitor
redirect(),notFound()or error fromgetServerSidePropson a shell hit arrives after the shell's200. The page then finishes itself: anhttp(s)or relative redirect that sets no cookies becomeslocation.replace()(with a<meta refresh>for visitors without JavaScript); anything else reloads once with a short-lived__gio_ppr_bypasscookie that skips the stored shell, to get the real status,Locationand cookies. A guard answers before any shell is sent. - Rendering credential-derived props outside a Suspense boundary breaks the contract: the first visitor's values would be stored in the shell.
- The holes hydrate like the rest of the page, so the code they render runs in the browser too: a
*.server.tsimport there keeps the whole route from hydrating. Load data ingetServerSideProps, or fetch it from a route handler. shellis read frompage.tsxonly.
Related
Version history
| Version | Changes |
|---|---|
v0.1.0-beta.8 | Hydration data streams after the shell; a shell holding content rendered from credentials is not stored; a per-visitor redirect() or notFound() on a shell hit reaches the visitor; renders that recovered from a Suspense error are not stored. |
v0.1.0-beta.7 | Introduced. |