URL and session data in the shell
Make personalized routes instant: resolve URL data at prefetch time (params needs Suspense even under generateStaticParams) and choose extract-and-pass vs use cache: private.
The two things the shared shell leaves out
The shared App Shell omits content that varies by URL and content that varies by session. Personalized routes are exactly where teams assume "this can't be instant." Both can be, and the two are resolved by different mechanisms.
URL data (params, searchParams) is opt-in per link with prefetch={true}. Session data (from cookies()/headers()) is automatic: it already rides the App Shell, cached per session in the browser.
Resolve URL data at prefetch time
A prefetch={true} link resolves its own URL data ahead of the click, because the value is known from the href. The prerender advances through static and cached work, then stops at the first uncached read and falls back to the surrounding boundary, the one Lesson 3 put in place.
// app/search/page.tsxexport default function SearchPage({ searchParams }) { return ( <> <h1>Search</h1> <Suspense fallback={<ResultsSkeleton />}> <Results searchParams={searchParams} /> {/* forward the promise */} </Suspense> </> )} async function Results({ searchParams }) { const { q } = await searchParams // awaited HERE, inside the boundary return <ResultList items={await search(q)} />} async function search(q: string) { 'use cache' // each query cached once, reused return db.search(q)}Coming from SSG you may expect a predefined param to be a plain value. It is not: a params read still needs a Suspense boundary even under generateStaticParams, because a statically known param still belongs to one URL. Awaiting it at the page top blocks the shell.
Session data is already in the shell
A route that reads cookies() or headers() gets an App Shell that includes its session data, cached per session in the browser, with no per-link prefetch. The catch: a lookup based on that session data still needs a cache lifetime, and 'use cache' cannot read cookies() inside. Two patterns bridge the gap.
| Pattern | Use when |
|---|---|
| Extract and pass | The result is shared across many sessions. Read the cookie outside, pass the value in. |
'use cache: private' | The result is tied to one session, or the runtime read is buried and cannot be lifted out. |
Extract and pass, the default
Read the cookie outside the cached function and pass the value as an argument. The cached function gets a deterministic signature, so sessions sharing that value share the entry, and traffic to the data scales with the number of teams, not sessions. Reach for this first.
// Shared across sessions on the same team.import { cookies } from 'next/headers' async function UserNav() { const team = (await cookies()).get('team')?.value // read OUTSIDE the cache const topics = await getTopics(team) return <nav>{/* ...topics */}</nav>} async function getTopics(team: string | undefined) { 'use cache' // keyed on the argument, sessions share entries return db.topics.forTeam(team)}use cache: private, the exception
When the lookup is tied to a single session, or the runtime read is buried inside an auth helper you cannot refactor, use 'use cache: private'. It permits cookies() inside and caches the result in the browser only.
// Tied to one session — cannot be lifted out.import { cookies } from 'next/headers' async function getUser() { 'use cache: private' // cached in the BROWSER only, never on the server const session = (await cookies()).get('session')?.value return db.users.findBySession(session)}Because it is per session, 'use cache: private' is never in the static shell served to everyone; it lands in the per-session App Shell. For it to ride the shell ahead of the click, its stale time must be at least 5 minutes. Colocate the directive as close to the runtime read as possible, since everything in scope shares one lifetime.
A dashboard reads a `session` cookie and looks up the one signed-in user's profile. Which approach fits?
When prefetch={true} actually pays off
It earns its per-link server render only when part of the tree depends on URL data, and that part has a known cache lifetime, and the traffic justifies it. Skip it when the prefetch cannot beat the App Shell.
| Situation | Choice |
|---|---|
View depends on cached searchParams/params. | Yes. Resolve URL data before the click. |
| Little URL dependency; the App Shell is enough. | No. Default link. |
| Dependent content must be fresh every request. | No. The prerender stops at the same fallback. |
| Rarely clicked, or many links on screen. | No. Prefetch on hover instead. |
What you learned
- URL data is opt-in per link; session data rides the App Shell automatically.
paramsneedsSuspenseeven withgenerateStaticParams.- Extract-and-pass for shared lookups;
'use cache: private'for personal ones. 'use cache: private'caches in the browser only and needsstale≥ 5 min to ride the shell.prefetch={true}pays off only when cached URL data and traffic justify the render.
That closes the course. The cheat sheet collects every rule, migration mapping, and API in one page for reference.