Prerendering and the static shell

Next.js · Practical · 14 min

With Cache Components, every route is a mix of prerendered and request-time parts; placing them well decides speed.

With Cache Components (on by default in new apps), Next.js renders each route at build time and keeps whatever can complete as a static shell: HTML plus an RSC payload that can be served instantly from a CDN. Parts that need request data are left as holes that stream in at request time. This is called Partial Prerendering.

What can be prerendered:

  • static markup, module imports and pure computation,
  • results of 'use cache' functions and components (next lessons).

What can't, and must be wrapped in <Suspense> so the rest of the page can still prerender:

  • runtime APIs: cookies(), headers(), searchParams, and params that aren't known at build time,
  • uncached data: fetches and database queries without 'use cache',
  • per-request values like Date.now() or crypto.randomUUID(), which require await connection() first.

If such access isn't inside a Suspense boundary, the dev overlay reports a blocking route and suggests the fix. The deeper in the tree you await, the more of the page lands in the shell: pass the params promise down instead of awaiting it in a layout.

`generateStaticParams` returns the param values to prerender at build time, e.g. your 100 most popular product slugs. Other values render the shell immediately and fill in on demand.

Apps without Cache Components use the previous model, where any runtime API makes the whole route dynamic.

Example

// app/products/[slug]/page.tsx
import { Suspense } from 'react'
import { cookies } from 'next/headers'
import { connection } from 'next/server'

export async function generateStaticParams() {
  return [{ slug: 'trail-runner' }, { slug: 'city-sneaker' }]   // prerendered at build
}
async function Greeting() {
  const name = (await cookies()).get('name')?.value ?? 'guest'   // runtime API
  return <p>Welcome back, {name}</p>
}
async function RequestId() {
  await connection()                               // opt into request time, then random is fine
  return <small>Request {crypto.randomUUID()}</small>
}
export default async function Product({ params }: PageProps<'/products/[slug]'>) {
  const { slug } = await params
  return (
    <main>
      <h1>{slug}</h1>                                {/* in the static shell */}
      <Suspense fallback={<p>Hello!</p>}><Greeting /></Suspense>
      <Suspense fallback={null}><RequestId /></Suspense>
    </main>)
}

Remember

  • Each route = prerendered static shell + Suspense holes filled at request time
  • Runtime APIs, uncached data and per-request values must sit inside <Suspense>
  • generateStaticParams prerenders known dynamic params; connection() defers to request time

Watch out for

  • Reading cookies() at the top of a layout, which pushes everything below it out of the static shell.

Check yourself

  1. With Cache Components, a page reads cookies() in one small component. How do you keep the rest of the page prerendered?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: nextjs.org/docs/app/getting-started/caching