Slow app?Modern Next.js is fast.

One route can now serve a prerendered shell and stream its per-request data into it. Three primitives make that work.
This page explains each one and lets you drive it.

Suspense, Partial Prerendering, and Cache Components

The three primitives stack. Suspense marks where a route can split. Partial Prerendering delivers the two halves separately. Cache Components decides what gets cached and turns Partial Prerendering on by default.

  • Suspense

    The boundary decides what waits.

    A Suspense boundary splits a route in two: the part that can render now, and the part still waiting on data. Everything outside the boundary ships in the first response.

  • Partial Prerendering

    One route, two delivery modes.

    Partial Prerendering serves a prerendered static shell immediately and streams the dynamic regions into it at request time, within a single route. Before Next.js 16 a route was static or dynamic, never both.

  • Cache Components

    You mark data cached, not routes dynamic.

    Cache Components makes data fetching dynamic by default and lets you opt individual functions or components into caching with the use cache directive. Enabling it also makes Partial Prerendering the default in the App Router.

See it: two routes, one difference

Two Next.js routes render the same dashboard from the same query. The only thing that differs is where the data is awaited. Run them and watch when each one appears.

Added latency
Awaiting at the top
Press the button to render this route.
  1. Shell painted
  2. Data streamed in
  3. Complete

The page awaits its data before returning any markup. The header, sidebar, and filters never read that query, and they wait for it anyway.

Inside a Suspense boundary
Press the button to render this route.
  1. Shell painted
  2. Data streamed in
  3. Complete

The shell is sent on the first response and the table streams in behind it, into space the layout already reserved. Nothing shifts when the rows land.

The only difference between these two routes is a Suspense boundary. Both panes render for real on every run. The latency is added on the server so the gap is visible at normal speed; the streaming itself is not simulated. Open a demo route directly to check it yourself.

Awaiting at the top
async function Page() {
  const members = await loadMembers();

  return (
    <PageBody>
      <PageHeader />
      <TableFilters />
      <MembersTable members={members} />
    </PageBody>
  );
}
One await, and the whole page is behind it.
Inside a Suspense boundary
function Page() {
  const membersPromise = loadMembers();

  return (
    <PageBody>
      <PageHeader />
      <TableFilters />
      <Suspense fallback={<TableSkeleton />}>
        <MembersTable promise={membersPromise} />
      </Suspense>
    </PageBody>
  );
}
The promise is created here but awaited further down.

Suspense: the boundary decides what waits

This is the primitive everything else is built on, and the one most often placed wrong. Move it and watch what lands in the first response.

The first response

Painted immediatelyWaiting on data

The component tree

  • <AppHeader />
  • <Sidebar />
  • <PageHeader />
  • <TableFilters />
  • <MembersTable />reads data
function Page() {
  return (
    <PageBody>
      <PageHeader />
      <TableFilters />
      <Suspense fallback={<TableSkeleton />}>
        <MembersTable />   {/* only this waits */}
      </Suspense>
    </PageBody>
  );
}

Everything that can render without the query does. Only the table waits, inside space the layout already reserved for it.

A Suspense boundary draws the line between what can render now and what has to wait. Everything outside it ships in the first response. Everything inside it shows the fallback until its data resolves. Pushing the boundary further down the tree is usually the whole fix, because the smaller the waiting region, the more of the page arrives on click.

A boundary does not make your queries faster

This is the mistake that survives the migration. Both routes below use identical boundaries. Only the awaits moved. Watch the completion times.

Added latency
Awaited in sequence
Press the button to render this route.
  1. Shell painted
  2. Data streamed in
  3. Complete

The second query does not start until the first resolves, so the region costs the sum of both.

Started together
Press the button to render this route.
  1. Shell painted
  2. Data streamed in
  3. Complete

Both queries are in flight before either is awaited, so the region costs the slower of the two.

Both routes have the same Suspense boundary. Only the awaits differ. Both panes render for real on every run. The latency is added on the server so the gap is visible at normal speed; the streaming itself is not simulated. Open a demo route directly to check it yourself.

Sequential
async function Region() {
  const stats = await loadStats();
  const members = await loadMembers();

  return <Dashboard stats={stats} members={members} />;
}
Two round trips, back to back.
Parallel
async function Region() {
  const [stats, members] = await Promise.all([
    loadStats(),
    loadMembers(),
  ]);

  return <Dashboard stats={stats} members={members} />;
}
Same two queries, overlapping.

Boundaries resolve independently

Two regions, two boundaries, two query speeds. The fast region does not queue behind the slow one, and neither queues behind the shell.

Added latency
Two boundaries, one route
Press the button to render this route.
  1. Shell painted
  2. Data streamed in
  3. Complete

The stats row arrives first, the table follows. Each boundary swaps its own fallback as its own data resolves.

One route, two boundaries, deliberately different latencies. Both panes render for real on every run. The latency is added on the server so the gap is visible at normal speed; the streaming itself is not simulated. Open a demo route directly to check it yourself.

Independent regions
async function Page() {
  const statsPromise = loadStats();
  const membersPromise = loadMembers();

  return (
    <PageBody>
      <Suspense fallback={<StatsSkeleton />}>
        <Stats promise={statsPromise} />
      </Suspense>

      <Suspense fallback={<TableSkeleton />}>
        <MembersTable promise={membersPromise} />
      </Suspense>
    </PageBody>
  );
}
Both promises start before either boundary is reached, so the two regions overlap rather than queue.

Partial Prerendering: one route, two delivery modes

Partial Prerendering serves a prerendered static shell immediately and streams the dynamic regions into it at request time, within the same route.

Before Next.js 16, a route was static or it was dynamic, and one per-request read anywhere in it decided for everything. Read a cookie in a layout to pick between “Sign in” and “Dashboard” and the marketing page underneath it became dynamic too. Most apps ended up marking whole route groups force-dynamic and giving up the cache.

Partial Prerendering ends that trade. A route now ships two things. The shell — layout, navigation, headings, table columns, empty states — is generated at build and served on the first byte. The regions inside your Suspense boundaries render per request and stream into the space the shell already reserved.

That reserved space is why nothing jumps as data arrives. The browser has already laid the page out. Rows appear inside a frame that was measured before the query returned.

The practical consequence is that the shell is not a loading screen. It is the real page, already interactive, with holes in it. Links work. Filters focus. Only the parts that genuinely depend on per-request data are still missing.

You do not switch this on separately. Enabling cacheComponents makes Partial Prerendering the default in the App Router, which is why the experimental.ppr flag and the experimental_ppr route segment config were removed in Next.js 16.

Cache Components: you mark data cached, not routes dynamic

The inversion is the whole idea. Pick a cache profile and see what a request gets at different points in its life.

Cache profile

Frequently updated content

A request arrives
stale
5 minutes
How long the client reuses its copy before checking the server at all.
revalidate
1 minute
How often the server refreshes, still serving the old value meanwhile.
expire
1 hour
The point past which a stale value stops being served at all.

Served from cache

Inside the 5 minutes stale window the client reuses its cached value without asking the server at all.

import { cacheLife } from 'next/cache';

async function loadMembers(accountId: string) {
  'use cache';
  cacheLife('minutes');

  return db.query.members.findMany({
    where: eq(members.accountId, accountId),
  });
}

Notice the inversion. You are not marking this route dynamic; you are marking one function cached, and anything you leave unmarked stays dynamic. That is exactly what lets a single route serve a prerendered shell and still show data that belongs to one visitor.

Static, dynamic, and partial rendering compared

Static and dynamic rendering still exist, and they still mean what they always meant. What changed in Next.js 16 is that the choice is no longer made once, for an entire route.

ModeBefore Next.js 16With Cache Components
Static (prerendered)Generated at build. Fast everywhere, but the entire route had to be free of per-request data. One session read anywhere and the route lost its prerender.Still generated at build. The difference: a per-request read no longer disqualifies the route, only the region around it.
Dynamic (server-rendered)Rendered per request. Correct for personalised data, but the visitor waits for the slowest query before seeing anything at all.Still rendered per request, but only the parts that need to be. The rest was already sent.
Partial (shell plus stream)Not available. A route was one or the other, and a single file made that choice for every visitor.The default. A prerendered shell serves immediately and dynamic regions stream into it, in the same route, on the same response.

How to think about the new model

The API surface is small: one directive and two helpers. The real adjustment is in how you picture a page, and these are the three places it usually sticks.

  1. The shell is not a loading state

    A skeleton stands in for a page that has not arrived. A shell is the page itself: laid out, styled, interactive, with holes where per-request data goes. That distinction changes what you put in it. A shell should carry everything true for every visitor — navigation, headings, table columns, empty states, filter controls. Treat it as a loading screen and you will under-fill it, and your reader still watches the layout assemble itself.

  2. The boundary is the API, not the directive

    Adopting this model is mostly not about typing use cache. It is about deciding where the boundaries go, which means working out which parts of a page truly depend on per-request data and which were merely written as though they did. Layouts that await a session to choose between two links. Page headers handed a fully loaded object just to read a name off it. Tables that could render their own chrome. Those are the components that move. The directive is the last five minutes of the job.

  3. Dynamic by default is the safer default

    Making everything dynamic and then opting pieces into caching looks backwards until you compare the failure modes. Under the old model, forgetting to mark a route dynamic could serve one visitor another visitor's data: a correctness bug with a security shape. Under this one, forgetting to cache something just makes it slower than it needed to be. You find the second in an audit. You find the first in an incident report.

Five Cache Components behaviours that catch people out

None of these are obvious from the API surface. We hit every one porting three kits, and most fail in a way that points you at the wrong file.

  • next build stays green when the migration silently fails

    Cache Components

    The build passes. A route that blocks on runtime data is still valid, it just is not instant.

    Audit every route in a browser with the dev overlay. next build --debug-prerender will not flag all of them.

  • searchParams outside a boundary only fails on client navigation

    Suspense

    A page that awaits searchParams at the top loads perfectly on a direct hit and reports nothing. Click a <Link> to it and the overlay flags it.

    Auditing by visiting URLs will not find these. Grep for await searchParams instead.

  • <Suspense> does not fix an unstable value

    Cache Components

    Date.now(), new Date(), and Math.random() error rather than degrading, because the same render would produce a different answer next time.

    Fix by what the value is for: per-request needs await connection(), stable-for-a-while needs use cache with cacheLife, browser-only needs use client, telemetry needs performance.now().

  • Never wrap a use cache function in React’s cache()

    Cache Components

    The build fails with Invalid value used as weak map key, reported against generateMetadata rather than the loader that caused it.

    The wrapper is redundant anyway: use cache dedupes across requests, not just within one.

  • Your Playwright suite will break, and the failure will lie to you

    Partial Prerendering

    Cache Components enables React’s <Activity>, so the previous route stays mounted and hidden. Every data-testid matches twice after a navigation. page.click() takes the first match, usually the invisible one, then waits for it to become visible until the test times out.

    Scope selectors and containers with :visible.

What Next.js 16.3 adds: root params

What root params changes

next/root-params lets any layout read a root dynamic segment such as [locale] directly, with no prop drilling and no headers() call. That second part matters more than it sounds. next-intl's requestLocale is a lazy getter that calls headers() the moment you read it, and that one read is enough to make every route in the app request-bound. Root params removes it.

It does not exist before 16.3, and next typegen on an older version does not error — it emits no root params file at all, so you get a missing type instead of a message telling you why.

Cache Components across 72 routes

Applying this to one page takes an afternoon. Applying it to every route in a SaaS kit, including auth, billing, admin, blog, docs, and a multi-tenant dashboard, is the actual work. Here is what the build reports afterwards.

15Fully static
Prerendered at build and served without touching the server.
54Prerendered shell
A static shell serves immediately while the dynamic regions stream in.
3Fully dynamic
API route handlers that only ever run per request. Nothing to prerender.

Route counts from next build on the Next.js Drizzle kit after the migration. Before it, one route prerendered anything and 37 were fully dynamic. No route opts out of instant navigation with export const instant = false.

Frequently asked questions about Cache Components

Why do Next.js apps feel slow to navigate?
Because the default pattern awaits data before rendering anything. When a page awaits a query at the top, the browser has nothing to paint until that query returns, including the parts of the page that never touch the result. Moving the data fetch inside a Suspense boundary lets the rest of the page render immediately.
What are Next.js Cache Components?
Cache Components is the Next.js 16 rendering model. Data fetching is dynamic by default and you opt individual functions or components into caching with the use cache directive. Next.js prerenders a static HTML shell that serves immediately while dynamic content streams in, letting one route mix static and dynamic content.
Do I need Next.js 16 to use Cache Components?
Yes. The cacheComponents flag was introduced in Next.js 16.0 and enables the use cache directive along with cacheLife and cacheTag. It also makes Partial Prerendering the default in the App Router, which replaced the experimental ppr flag and the experimental_ppr route segment config.
What is the difference between Partial Prerendering and Cache Components?
Partial Prerendering is the delivery behaviour: a static shell serves immediately and dynamic regions stream into it. Cache Components is the model you program against, and enabling it makes Partial Prerendering the default. You do not turn Partial Prerendering on separately.
Can I apply this to an existing Next.js app?
Yes, and incrementally. Adding Suspense boundaries improves an app under the old rendering model on its own, before you enable cacheComponents. The order matters: restructure components and add boundaries first, then turn the flag on.
Which Makerkit kits ship this rendering model?
All three Next.js kits. It ships as v4 on the Next.js Supabase kit and as v2 on the Next.js Drizzle and Prisma kits. The version numbers differ only because the kits were on different majors when the work landed.

Start on the modern rendering model

Makerkit ships Suspense boundaries, Partial Prerendering, and Cache Components applied across every route, with a migration guide for the app you already have.