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 cachedirective. 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.
- Shell painted
- Data streamed in
- 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.
- Shell painted
- Data streamed in
- 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.
async function Page() { const members = await loadMembers(); return ( <PageBody> <PageHeader /> <TableFilters /> <MembersTable members={members} /> </PageBody> ); }
function Page() { const membersPromise = loadMembers(); return ( <PageBody> <PageHeader /> <TableFilters /> <Suspense fallback={<TableSkeleton />}> <MembersTable promise={membersPromise} /> </Suspense> </PageBody> ); }
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
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.
- Shell painted
- Data streamed in
- Complete
The second query does not start until the first resolves, so the region costs the sum of both.
- Shell painted
- Data streamed in
- 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.
async function Region() { const stats = await loadStats(); const members = await loadMembers(); return <Dashboard stats={stats} members={members} />; }
async function Region() { const [stats, members] = await Promise.all([ loadStats(), loadMembers(), ]); return <Dashboard stats={stats} members={members} />; }
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.
- Shell painted
- Data streamed in
- 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.
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> ); }
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.
- 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.
| Mode | Before Next.js 16 | With 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.
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.
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.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.
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.