Rendering and caching
The cacheable unit is the anonymous document. Personalisation never dynamises a page that could otherwise be cached. Client-side navigation, prefetch and streaming are for authenticated, app-like flows — off by default on anonymous SEO surfaces.
Two goals are in genuine tension. Streaming, <Suspense> and prefetch optimise perceived latency
for an already-engaged user. The highest-value commerce traffic is the opposite case: anonymous
first-hits and bots, where CDN offload and servable raw HTML are what matter. On an anonymous
surface the document wins; perceived-latency features move inside the personalised islands.
This storefront is self-hosted behind a customer-managed CDN, which caches assets aggressively but does not automatically edge-cache RSC payloads, and keys on the query string.
A route has two answers, not one
Section titled “A route has two answers, not one”The pattern says how a route renders. Its data class says whether the response may be cached. Every route needs both and neither implies the other — an order confirmation streams like any request-dependent page and is also personal, so it is never cached.
The page-type matrix
Section titled “The page-type matrix”| Page-type | Render | HTML in CDN? | Client nav / prefetch | Streaming |
|---|---|---|---|---|
| PLP / PDP / CMS / home (anonymous) | Static/ISR; all indexable content in the first flush | Yes, ≥ 900 s | Hover-prefetch only, until the route is static | No, around indexable content |
| Cart / account / checkout / B2B tools | Dynamic | Document no | On — the user runs JS anyway | Fine, improves the app feel |
| Personalised island on a cached page | Client component over a per-user endpoint | Page stays cached | Encouraged | Free |
The three patterns
Section titled “The three patterns”1. Server-rendered with client leaves
Section titled “1. Server-rendered with client leaves”Anonymous, indexable pages. Everything is server-rendered; the only JavaScript is interactive leaves.
export default async function ProductPage({ params }) { const { slug } = await params; const product = await getProduct(slug);
return ( <ProductDetail product={product}> <ProductGallery product={product} /> <ProductPriceContract sku={product.defaultSku} /> {/* client leaf */} <ProductJsonLd product={product} /> </ProductDetail> );}No <Suspense> — indexable content is awaited into the first flush. The server passes the shared
value down as a prop so an island renders something real in HTML rather than a skeleton. An
island adds; it does not replace, so nothing a visitor already read is contradicted.
2. Server-rendered with a streaming region
Section titled “2. Server-rendered with a streaming region”Pages whose content depends on the request — a session, a search term, a filter.
export default async function OrdersPage({ searchParams }) { return ( <OrderHistoryLayout> <OrderHistorySearch /> <Suspense fallback={<OrderHistorySkeleton />}> <OrderHistoryResults searchParams={searchParams} /> </Suspense> </OrderHistoryLayout> );}Request data is awaited inside the boundary, never in the page body. searchParams,
cookies() and headers() are promises; awaiting one in the page body makes the whole route
dynamic and nothing streams.
Search is a form, not a component. A method="get" form navigates to ?q=shoes with no client
JavaScript and works before hydration. Reach for a Server Action only to normalise before landing.
3. Server-prefetched, client-owned
Section titled “3. Server-prefetched, client-owned”Pages built around mutation, where several regions must move together.
export default async function CartPage() { const queryClient = new QueryClient(); await queryClient.prefetchQuery(cartQueryOptions());
return ( <HydrationBoundary state={dehydrate(queryClient)}> <CartOverview /> </HydrationBoundary> );}First paint is real content and the client hydrates with a warm cache — no mount-time waterfall. One entity, many readers: line items and summary read the same cache entry, so one optimistic write moves both and undo is re-applying the previous entity. Server Actions fit write-then-navigate, not write-then-stay-and-reconcile.
Passing a promise across the boundary
Section titled “Passing a promise across the boundary”When the fetch should start on the server but the consumer must be interactive, pass the unawaited
promise and unwrap it with use(). Only for interactive consumers — a component that renders and
nothing more should be awaited on the server.
Conventions
Section titled “Conventions”Indexable content is never streamed and never ssr:false. Links, titles, public prices,
pagination rel links, JSON-LD and canonical tags belong in the first synchronous flush. That
rules out <Suspense> (post-flush chunks raw-HTML crawlers do not execute) and
next/dynamic(…, { ssr: false }) (never in SSR HTML).
<Suspense> answers can this region arrive late?; next/dynamic answers must this JS ship up
front? Both must answer no for anything indexable.
Prefetch is hover-only by default. Viewport prefetch fires an RSC request per in-view link — an origin flood on a CDN that does not cache RSC. Once a route is static, native prefetch of it is cacheable and can be enabled.
Personalisation never dynamises a cacheable page. No session read in the render path of an
anonymous route. The one sanctioned document-level split is the cookie-gated Cache-Control
header. This constrains anonymous routes only: on a page that is already dynamic — cart, account,
checkout — the server has the session and should render per-user content directly. Fetching it
client-side there buys nothing and costs a waterfall.
Vary only on axes that legitimately change the anonymous document — locale and currency, via the URL path, never a per-user cookie.
What forces a route dynamic is reading a dynamic API in the render path: searchParams,
cookies(), headers(), draftMode().
When the pattern depends on who is asking, split the route
Section titled “When the pattern depends on who is asking, split the route”One URL cannot serve both: reading the session to decide makes the route dynamic for everyone and the anonymous half loses its cache.
/wishlist anonymous → pattern 1/account/wishlist authenticated → pattern 3Per-customer price
Section titled “Per-customer price”A static page serves one document to everyone, so a per-customer price arrives client-side. Whether that is acceptable turns on one question: is the anonymous price still true after login?
- Yes — a list price remains a real fact. Keep the page static, make the island additive, and reserve the space so nothing shifts.
- No — where contract pricing means nobody pays list, showing list first states a price the customer will never pay. Authenticated requests bypass the CDN anyway, so render the contract price server-side. One query, no island, no flip.
Stock follows the same don’t-contradict rule, with one asymmetry: JSON-LD availability may carry
the cached value because crawlers expect staleness; the visible badge waits for the live check,
because a human reads it as a promise.
Acceptance test
Section titled “Acceptance test”From the raw HTML response with no JavaScript executed:
- Product links, titles and public prices are present.
- Canonical and pagination links are present.
- Product / ItemList JSON-LD is present.
- None of the above is inside
<Suspense>or anssr:falsedynamic import. - An anonymous request carries a shared-cache
Cache-Controland is a CDN hit. - No viewport prefetch storm — hover-prefetch only, unless the route is static.

