GioJSdocs
On this page

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

OptionTypeDefaultDescription
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 - getServerSideProps included, 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 getServerSideProps answers this visitor with a redirect, a 404 or 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.

  • getServerSideProps may read cookies on a PPR page - that is the point. Render what comes from them only inside a Suspense boundary that suspends (with use() on a per-request promise, for example).
  • Before the shell is stored, the worker checks it. If getServerSideProps read 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>). A generateMetadata that reads credentials, or uses the props of a getServerSideProps that 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 (or 0), or getServerSideProps returned response headers. The worker logs shell='cache' requires a shareable render (revalidate set, no per-request headers) - falling back;
  • a Node plugin with an onResponse hook is installed (it needs the whole body);
  • the request is not a GET (a HEAD, 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=hit

Good to know

  • A loading.tsx is 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 from getServerSideProps on a shell hit arrives after the shell's 200. The page then finishes itself: an http(s) or relative redirect that sets no cookies becomes location.replace() (with a <meta refresh> for visitors without JavaScript); anything else reloads once with a short-lived __gio_ppr_bypass cookie that skips the stored shell, to get the real status, Location and 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.ts import there keeps the whole route from hydrating. Load data in getServerSideProps, or fetch it from a route handler.
  • shell is read from page.tsx only.

Version history

VersionChanges
v0.1.0-beta.8Hydration 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.7Introduced.