Skip to content

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.

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.

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

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.

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.

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.

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 3

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.

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 an ssr:false dynamic import.
  • An anonymous request carries a shared-cache Cache-Control and is a CDN hit.
  • No viewport prefetch storm — hover-prefetch only, unless the route is static.