TL;DR
If a route in your Next.js App Router feels like full SSR—long TTFB, janky client navigations—try a conservative route-level "use cache" plus a small Suspense shell to let Partial Prerendering (PPR) do the heavy lifting. In practice this often drops TTFB from ~700ms to ~60–80ms and makes navigations feel instant.
Why this matters
Next.js's App Router and Cache Components model favor explicit caching. By marking expensive server work as cacheable and placing Suspense boundaries close to uncached parts, Next.js can serve a static shell immediately and stream dynamic content into the same response. That static shell is what the browser receives first, so perceived navigation latency plummets.
Key outcomes you can expect
- Much lower TTFB for client navigations and direct hits when the shell is cached.
- Smooth incremental rendering: the shell appears instantly, slow bits stream in.
- Smaller surface area for client-side code and fewer client-rendered fallbacks.
The simple recipe
1) Add "use cache" at a server component boundary that owns expensive fetches. 2) Turn heavy fetches into promises and pass them down into the cached boundary. 3) Wrap slow subtrees with Suspense and a tiny skeleton UI so the static shell can return immediately.
Concrete example
A single-file route illustrating the pattern:
// app/projects/[id]/page.tsx
'use cache'
import React, { Suspense } from 'react'
import ProjectDetails from './ProjectDetails' // server component
import { fetchProject } from '@/lib/api'
export default async function Page({ params }) {
const projectPromise = fetchProject(params.id) // cached by 'use cache'
return (
<div>
<h1>Project</h1>
<Suspense fallback={<div className="skeleton">Loading summary…</div>}>
{/* ProjectDetails reads the cached promise and streams when ready */}
{/* @ts-expect-error Server Component inside Suspense */}
<ProjectDetails promise={projectPromise} />
</Suspense>
</div>
)
}
Why this works:
- The
"use cache"directive caches the return value of async work inside the boundary so repeated navigations don't re-run expensive fetching. - The surrounding
Suspenseallows Next.js to return the static shell immediately while streaming the cached/dynamic content into the same HTTP response (PPR behavior).
Migration checklist (route-level)
1) Identify the server component entry for the route (page or layout).
2) Add 'use cache' at the top of that file to mark it cacheable.
3) Turn heavy fetches into promises consumed inside the boundary (fetch once, pass promise down).
4) Wrap slow subtrees with Suspense + a tiny skeleton UI for smooth incremental rendering.
5) Measure TTFB and navigation latency; iterate (expand cache boundaries or move more work behind Suspense).
6) Optional: enable Turbopack dev server for much faster local HMR and rebuild cycles.
Practical tips and caveats
- Next.js
use cachecaches results, not execution: the cached value is serialized into the RSC payload and can become part of the static shell. - Don't read request-time APIs (cookies(), headers(), searchParams directly) inside a
'use cache'scope. Those must stay inside a Suspense-wrapped dynamic subtree or the build will error. - Be conservative at first: wrap only the heavy server boundary you control. You can expand later if more saving is needed.
- Use
cacheLife()orcacheTag()when the cached data needs TTLs or on-demand invalidation. For many UI routes the default cached shell lifespan is fine. - Per-deploy cache reset: cache keys include build id. A new deploy will reset cache entries—account for this during rollouts.
- Consider
use cache: remoteonly if you need a shared durable store across instances and you have very high hit rates.
Measuring impact
Track these metrics before and after:
- TTFB (client navigations and direct loads)
- First Contentful Paint (FCP) / Largest Contentful Paint (LCP) for the critical shell
- Time-to-interactive for critical interactions (if any client code is involved)
Example outcome from a real route: replacing a fully dynamic server render with a cached server boundary + small Suspense shell reduced TTFB from ~700ms to ~60–80ms for navigations and eliminated the janky transitions that frustrated product owners.
Turbopack: optional dev tweak
Using the Turbopack dev server (now stable for development in modern Next.js versions) can dramatically improve local dev iteration (faster HMR and rebuilds). You don't need it for production speedups, but enabling next dev --turbo (or using the turbopack option in next.config) reduces the time you wait between code change and hot update—handy when iterating with Suspense boundaries.
When not to use "use cache"
- Per-user, highly personalized content that must always be fresh. Use
use cache: privatecarefully or keep those parts dynamic within Suspense. - If your route needs request-specific values everywhere (no cachable content), keep it dynamic via
connection()or move to fully server-side rendering; PPR is about mixing static and dynamic pieces, not forcing static where it shouldn't be.
Real-world checklist for your first experiment
- Pick a page that reads a large shared resource (catalogs, permissions graph, profile blobs) and re-fetches on every navigation.
- Add
'use cache'to the top-level server component for that route. - Convert the fetch to a promise and pass it down; wrap the subtree in Suspense with a small loader.
- Run
next build/next start(or test in dev with Turbopack) and measure TTFB and navigation latency. - Iterate: if more latency remains, expand the cache boundary or split additional slow trees into Suspense.
Final thoughts
Next.js's Cache Components + Partial Prerendering change the mental model: instead of choosing SSG vs SSR per route, you mark what is cacheable and where the dynamic holes are. Start conservatively: a route-level "use cache" + minimal Suspense shell often yields outsized improvements for perceived navigation speed and developer happiness.
What route in your app would you try this on first?
Top comments (0)