Skip to content
instant.by-design
L0510 min

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.

Two mechanisms, not one

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.

1

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.tsxtsx
// 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)}
Heading is static; results read the query inside Suspense; the query is cached, so it computes once per value.
generateStaticParams does not make params synchronous

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.

2

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.

Caching a session-based lookup
PatternUse when
Extract and passThe 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.
3

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.

app/dashboard/user-nav.tsxtsx
// 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)}
cookies() stays outside the scope; getTopics is keyed on team, so one team shares one entry.
4

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.

app/dashboard/user-nav.tsxtsx
// 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)}
cookies() lives inside the cached function — only legal under use cache: private.
It never joins the static shell

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.

Check yourself

A dashboard reads a `session` cookie and looks up the one signed-in user's profile. Which approach fits?

5

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.

Should this link use prefetch={true}?
SituationChoice
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.
  • params needs Suspense even with generateStaticParams.
  • Extract-and-pass for shared lookups; 'use cache: private' for personal ones.
  • 'use cache: private' caches in the browser only and needs stale ≥ 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.