Next.js

Partial Prerendering in Next.js 16, Explained on a Real Page

What partial prerendering does to a real Next.js page: what becomes static HTML, what streams, where the Suspense boundaries go, and the build errors to expect.

5 min read
Next.jsPPRApp Router
989 words5 min read

One page, two speeds

Partial prerendering is the idea that a page does not have to be entirely static or entirely dynamic. The parts that are the same for every visitor are rendered at build time and served from the edge as HTML. The parts that depend on the request are rendered when the request arrives and streamed into the same response. The visitor sees the static shell instantly and the dynamic parts fill in.

That is the whole concept. The interesting part is deciding where the line goes, and what happens at the boundary.

The page

Take a blog post page on this site. It has:

  • a header with the title, description and reading time
  • the article body, rendered from MDX
  • a table of contents derived from the headings
  • a sidebar with an author card, a related-posts list and a call to action
  • the site navigation and footer

Every one of those depends only on the content, not on who is asking. There is nothing request-scoped on the page. Under Cache Components, the entire page prerenders to static HTML and partial prerendering has nothing to stream. That is the correct outcome, and worth stating: most marketing pages should be fully static, and partial prerendering is for the pages that cannot be.

Now add one thing that depends on the request: a "Recently viewed" list driven by a cookie.

Where the line goes

The cookie read makes that component dynamic. Without a boundary, it makes the whole page dynamic, and the build tells you so:

Text
Error: Route "/blog/[slug]": Uncached data was accessed outside of a Suspense boundary.

The fix is to draw the line around the dynamic component with Suspense:

TSX
import { Suspense } from 'react'
import { RecentlyViewed } from './RecentlyViewed'

export default async function PostPage({ params }) {
  const { slug } = await params
  const post = await getPost(slug) // 'use cache' inside

  return (
    <article>
      <PostHeader post={post} />
      <PostBody post={post} />
      <aside>
        <AuthorCard />
        <Suspense fallback={<RecentlyViewedSkeleton />}>
          <RecentlyViewed />
        </Suspense>
      </aside>
    </article>
  )
}
TSX
// RecentlyViewed.tsx
import { cookies } from 'next/headers'

export async function RecentlyViewed() {
  const jar = await cookies()
  const slugs = jar.get('recent')?.value.split(',') ?? []
  const posts = await getPostsBySlugs(slugs) // cached per argument set
  return <ul>{posts.map((p) => <li key={p.slug}>{p.title}</li>)}</ul>
}

Now the header, body, author card, navigation and footer prerender. The skeleton prerenders in the sidebar slot. At request time, the server reads the cookie, renders the list, and streams it into the slot. One response, two speeds.

What the visitor experiences

The HTML arrives with the static shell complete, so the largest contentful paint is the article header or hero, served from cache. The recently-viewed skeleton is in place, so nothing shifts when the real list arrives. The stream closes when the dynamic part is done. Measured in the field, the page behaves like a static page for LCP and CLS, because for those metrics it is one.

The trap is putting the boundary too high. Wrap the whole article in Suspense because one sidebar widget is dynamic, and you have made the article stream too. The static shell becomes a spinner, and LCP waits for the request. Boundaries go around the smallest dynamic unit, not around the page.

What counts as dynamic

Anything that reads from the request: cookies(), headers(), searchParams, connection(). Anything that reads uncached data: a fetch without caching, a database call outside a 'use cache' function. Anything nondeterministic at render: Date.now(), Math.random(), unless inside a cached function.

Everything else is static, and the build treats it as static unless you tell it otherwise. That default is what makes partial prerendering safe: you cannot accidentally make a page slow by adding a static component, only by adding a dynamic one without a boundary, and the build refuses to let that through.

The cached-dynamic middle ground

Some things depend on the request but not much. A list of posts for the visitor's locale is dynamic per locale, but there are three locales, not three million visitors. The pattern is to read the request value in the page, then pass it into a 'use cache' function as an argument:

TSX
const locale = (await cookies()).get('locale')?.value ?? 'en'
const posts = await getPostsForLocale(locale) // 'use cache', keyed by locale

The page is dynamic, because it read a cookie, but the expensive part is served from a cache with three entries. Put it behind Suspense and the shell still prerenders.

The errors, decoded

"Uncached data was accessed outside of a Suspense boundary." Something dynamic is not wrapped. Find it, wrap the smallest unit.

"Route used Date.now() before accessing uncached data." Something read the clock during prerender. Cache it or move it to a Client Component.

"Route used searchParams without a Suspense boundary." Same as the first, for query strings. Reading searchParams in a page makes the page dynamic; wrap the component that uses them.

A page that was static is suddenly dynamic after a change. Look at what the change imported. A helper that reads headers() deep in a utility file makes every caller dynamic.

When to reach for it

Partial prerendering earns its keep on pages that are mostly shared and slightly personal: a product page with a stock count, a docs page with a login-aware header, a listing with a saved filter. It is unnecessary on pages that are entirely static, and unhelpful on pages that are entirely personal, where there is no shell worth prerendering.

This site's 470 routes are almost all fully static, by design. The handful of dynamic pieces are behind boundaries the build made us draw. If you are migrating an App Router project and the boundary errors are piling up, that is a sign the page has more request-scoped reads than it needs, and usually a sign of where the performance work should start.

All writingHire me for this