Skip to content

docs: scope React cache() guidance to Server Components in the auth guide and after() reference - #98579

Open
henryjayyu wants to merge 4 commits into
vercel:canaryfrom
henryjayyu:docs/cache-in-route-handlers
Open

docs: scope React cache() guidance to Server Components in the auth guide and after() reference#98579
henryjayyu wants to merge 4 commits into
vercel:canaryfrom
henryjayyu:docs/cache-in-route-handlers

Conversation

@henryjayyu

@henryjayyu henryjayyu commented Sep 11, 2026

Copy link
Copy Markdown

What

Two docs pages recommend React cache() in contexts where it does not memoize. This qualifies both, and fixes three smaller defects in the examples I had to touch anyway.

Previously filed as #98577 / #98578, both auto-closed by the triage bot for a missing reproduction link — they were created through the API, which bypasses the issue template and so never set the Documentation type the bot checks for. Opening a PR instead, as the docs report template suggests.

Why

React's reference says cache "is for use in Server Components only," and that calling a memoized function outside a component "will still evaluate the function but not read or update the cache." There is no error and no warning — the wrapper simply does nothing.

Measured on Next.js 16.3.4 / React 19.2.4, counting executions of the underlying function and comparing returned object identity:

context deduped in body deduped inside after after shares the body's cache
Server Component yes yes yes
Route Handler no no no
Server Function no no no

Consistent with packages/next/src/server/route-modules/app-route/module.ts containing no reference to React at all — no ReactSharedInternals, no getCacheForType — so cache() falls through to the pass-through in React's ReactCacheImpl.js.

This is not hypothetical. Following the Authentication guide's pattern in a production app, an auth wrapper resolved the caller once and then every service-layer call inside the handler re-resolved it, because the cache() wrapper did nothing: one GET performed three session decrypts and three identical permission-user database queries where one of each was needed. Diagnosing it required reading the compiled runtime, precisely because the documented pattern looks correct.

Changes

guides/authentication.mdx

  • Adds a "Good to know" after "You can then invoke the verifySession() function in your data requests, Server Actions, and Route Handlers" — cache only memoizes during a render pass, so it gives Server Actions and Route Handlers nothing. The page already uses the right phrase elsewhere ("during a React render pass"); it just was not carried into the sentence naming the call sites.
  • Adds the missing cache and redirect imports to both DAL snippets.
  • Guards session.user.role in the three verifySession-based examples. verifySession() returns { isAuth, userId } — there is no user property, so the unguarded access throws a TypeError. The Server Component examples already guard it with session?.user?.role, so this just makes the Server Action and Route Handler examples consistent. The Pages Router examples use a different getSession(req) and are untouched.

api-reference/functions/after.mdx

  • Scopes the cache bullet to Server Components. The page lists Server Components, Server Functions, Route Handlers and Proxy as supported contexts, and the bullet sat a few lines below that list, unqualified. Also records what does happen in a Server Component, which is better than the original promised: the callback shares the render pass's cache, so a value already computed during the render is not recomputed.

Prettier clean under the repo's .prettierrc.json.

The Route Handler example's unreachable 401

The example previously read:

const session = await verifySession()

if (!session) {
  return new Response(null, { status: 401 })
}

verifySession() either calls redirect(), which throws, or returns a truthy object — it never returns a falsy value, so the 401 was dead code. The only failure a Route Handler could actually produce was a redirect to /login, from an endpoint the same section says to "treat with the same security considerations as public-facing API endpoints."

Fixed by adding a non-redirecting getSession() to the DAL and using it in the Route Handler example, so the documented 401 is reachable and a programmatic caller gets a status code rather than a navigation. verifySession() is unchanged and remains the right choice for pages, which is where a redirect belongs.

getSession() is deliberately not cache()-wrapped — its callers do not render, so the wrapper would do nothing, which is the point of the rest of this PR.

I took this direction rather than the alternative (leave the redirect and delete the 401) because the guide's own framing of Route Handlers as public-facing API endpoints argues for it. Happy to flip it if you disagree — it is a small commit either way.

The role shape

Every App Router example read session.user.role, but nothing in the guide ever produces a nested user object — verifySession() returns { isAuth, userId }. So the access was undefined, the role checks could never pass, and before this PR it threw a TypeError outright.

Checked against the sibling Authentication with Cache Components guide, which models the session flat too:

export type SessionData = {
  userId?: string
}

So the fix is to read session.role, and to have verifySession() / getSession() forward the role from the decrypted payload. decrypt() returns the raw JWT payload, so the value was already reachable — the DAL was simply dropping it. That matches what the guide's own Tips section asks for:

The payload should contain the minimum, unique user data that'll be used in subsequent requests, such as the user's ID, role, etc.

createSession() is deliberately left alone. Whether a role belongs in the payload is the reader's decision, and threading one through would mean presuming a role column in the signup example's insert. The examples keep an explicit // Assuming you included 'role' in the session payload comment instead — the same caveat the guide already had, now attached to a path that works.

The two Pages Router examples use a different getSession(req) from an auth library and are untouched.

…erver Actions

The DAL example is cache()-wrapped and the guide then points readers at Server
Actions and Route Handlers, where cache() is a no-op: it evaluates the function
and neither reads nor writes the cache, with no error and no warning.

Also adds the missing cache/redirect imports to the DAL snippet, and guards the
role access in the verifySession-based examples. verifySession() returns
{ isAuth, userId }, so session.user.role throws; the Server Component examples
already guard it the same way.
…ponents

The bullet was unqualified, on a page that lists Server Components, Server
Functions, Route Handlers and Proxy as supported contexts. Deduplication only
happens in the first. Also records what does happen in a Server Component: the
after callback shares the render pass's cache, so a value already computed
during the render is not recomputed.
The 401 branch was unreachable. verifySession() either calls redirect(), which
throws, or returns a truthy object — it never returns a falsy value — so the
only failure a Route Handler could actually produce was a redirect to /login,
from an endpoint the same section says to treat like a public-facing API.

Adds a non-redirecting getSession() to the DAL and uses it in the Route Handler
example, so the documented 401 is reachable and a programmatic caller gets a
status code instead of a navigation. verifySession() is unchanged and still the
right choice for pages.

getSession() is deliberately not cache()-wrapped: its callers do not render, so
the wrapper would do nothing.
Every App Router example read session.user.role, but nothing in the guide ever
produces a nested user object: verifySession() returns { isAuth, userId }, so
the access was undefined and the role checks could never pass. The sibling Cache
Components guide models the session flat as well (SessionData = { userId }).

Reads session.role instead, and has verifySession()/getSession() forward the
role from the decrypted payload — decrypt() returns the raw JWT payload, so it
was already reachable and simply being dropped. This is the shape the guide's
own Tips section asks for: 'The payload should contain the minimum, unique user
data ... such as the user's ID, role, etc.'

createSession() is left alone: whether a role belongs in the payload is the
reader's decision, so the examples keep an explicit 'assuming you included role
in the session payload' comment rather than the guide presuming a role column.

The Pages Router examples use a different getSession(req) from an auth library
and are untouched.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant