Reading the session everywhere with one call shape
Read the Better Auth session the same way across every server context with a pair of helpers wrapped in React.cache, while the proxy does a cheap cookie-presence bounce.
Picture the dashboard you’re about to build. The header shows the signed-in user’s avatar and name. The page body is keyed to their data: their invoices, their numbers. The sidebar hides an admin link unless this particular user is an admin. That’s three places on one screen, rendered for one request, all asking the same question: who is this?
The same question comes up beyond rendering. When the user clicks “delete invoice,” the Server Action behind that button needs the answer before it writes anything, because an anonymous request must be refused. A route handler returning JSON to a mobile app needs it before it responds. And before any of that page even renders, something should bounce a signed-out visitor away from /dashboard entirely, cheaply, without doing real work.
So the question isn’t how do I read a session, since the previous lessons in this chapter already gave you sessions that exist and persist. The sharper question is this: now that sessions exist, what is the one way to read “who is this user?” that works in every server context, and where does each context legitimately diverge from it?
The answer is a single call shape you’ll write the same way everywhere, wrapped in two small helpers you’ll reach for instead of the raw call. By the end of this lesson you’ll have a getCurrentUser() / requireUser() pair living in lib/auth.ts that every downstream chapter imports without re-deciding anything, plus a minimal proxy.ts gate that gives the smoke test from the first lesson of this chapter somewhere to land. This read path leans on the session lifetimes and the cookie cache you configured in the previous lesson, so this is where that setup starts paying off.
The one call: auth.api.getSession
Section titled “The one call: auth.api.getSession”Before looking at any surface, learn the call on its own. It’s the spine of the lesson, and it’s two lines:
import { headers } from 'next/headers';
import { auth } from '@/lib/auth';
const session = await auth.api.getSession({ headers: await headers() });This is the part everything depends on. A Server Component doesn’t hand the auth library a cookie store on its own. You pass it the incoming request’s headers, and Better Auth reads the Cookie header off them. In Next.js 16 headers() is async, so await headers() is the canonical form. Forget the await and you pass a Promise where a Headers is expected, which is the most common way this read fails silently.
import { headers } from 'next/headers';
import { auth } from '@/lib/auth';
const session = await auth.api.getSession({ headers: await headers() });The server-side API on the auth instance from the first lesson of this chapter. It runs in-process: no network hop to your own server, just a function call.
import { headers } from 'next/headers';
import { auth } from '@/lib/auth';
const session = await auth.api.getSession({ headers: await headers() });The result is { user, session } | null. user is the typed user row; session is the typed session row, carrying expiresAt, ipAddress, and userAgent. A null here means anonymous: there’s no valid session cookie on the request. It never means error.
The shape is Promise<{ user, session } | null>, and that null is the part to pay attention to. A null is not a failure: it’s the library telling you that this request carries no valid session cookie. Every surface in this lesson differs only in how it reacts to that null. The read itself never changes.
One detail ties back to the previous lesson. You turned on the cookie cache there, so this call reads the signed …session_data cookie when the cache is fresh and falls through to the database otherwise. The payoff is that the call shape is identical either way. You never branch on “is the cache warm?”; you write getSession once and the library decides where the answer comes from. Under the hood it’s resolving the opaque session token you met when you learned the auth mental model, but at this layer that work is invisible, which is exactly the point.
Five surfaces, one call, different tails
Section titled “Five surfaces, one call, different tails”Five surfaces in your codebase need to know who the user is: layouts and Server Components, Server Actions, route handlers, Client Components, and the proxy. Four of them answer the question with the exact same getSession call, and the only thing that changes between those four is what they do when the answer is null. The proxy is the deliberate exception, and it gets its own section. You’ve already learned the call, so think of each surface as a different tail hanging off the same head.
The diagram below makes that concrete. One call sits in the center, and the lanes fanning out are the call sites that use it.
auth.api.getSession({ headers }) → { user, session } | null null, each site differs The call is identical everywhere; only what each site does with a null result changes. The proxy is the exception: it peels off before the call and never makes it at all.
The proxy is drawn apart because it’s the one site that does not make this call, and it gets its own section later. The other four are genuinely the same read with a different ending. The three server surfaces come first, side by side, then the client case.
export default async function DashboardLayout({ children,}: { children: ReactNode;}) { const user = await requireUser('/dashboard');
return <AppShell user={user}>{children}</AppShell>;}Read to drive identity-aware UI. A layout reads the session to show the user’s name and decide whether the admin link renders. The null tail here is one of two things: render the signed-out variant, or, when the whole subtree demands a session like everything under /dashboard, call requireUser and let it redirect. A layout that reads the session opts its subtree into dynamic rendering, the dynamic-by-default behavior from the chapter on Cache Components, and that’s intended rather than a problem. Read at the highest layout where the gate belongs, not in every leaf.
'use server';
export const archiveInvoice = async (id: string) => { const user = await getCurrentUser(); if (!user) return err('unauthorized', 'Please sign in to continue.');
// ...perform the mutation for this user};Read at the very top of every mutating action, before it writes. The null tail is to return the unauthorized discriminant of the Result type from the chapter on Server Actions, written as err('unauthorized', …), so the caller gets a typed refusal rather than a thrown error. Checking whether a user may perform this action, based on their role or their org, layers on top later in the wrapper this course calls authedAction. The read of who they are happens right here.
export const GET = async () => { const user = await getCurrentUser(); if (!user) { return Response.json( { title: 'Unauthorized' }, { status: 401 }, ); }
return Response.json({ user });};Same call, same shape. A route handler serving JSON to a non-browser client reads the session identically. The null tail is a 401 in the Problem Details shape from the chapter on route handlers: there’s no identity on the request, so the response says so in a status code a machine client can act on.
The fourth surface is a Client Component, and it’s worth naming now even though its full treatment comes at the end of the lesson. When a Client Component needs to know who the user is, you do not reach for an auth call inside it. You read the session on the server, in the layout or page that renders it, and pass the user down as a prop. The client never asks “who’s logged in?” on its own; it receives the answer.
That gives you one call with four endings. A layout redirects, an action returns an unauthorized Result, a route handler returns a 401, and a Client Component gets the user as a prop. Learn the head once, then vary the tail.
Read once per request with React.cache
Section titled “Read once per request with React.cache”Look back at that dashboard. The layout reads the session to build the shell. The header Server Component reads it for the avatar. The page body reads it to scope the data. Maybe the sidebar reads it too. That’s four reads of the exact same thing inside one request, and without help each one pays the cookie-cache decode, or worse, hits the database.
The fix is one line of wrapping. React ships a cache() function that memoizes a function for the duration of a single request: the first call inside the request runs the work, and every later call within the same request, anywhere in the tree, gets the same already-resolved Promise back. Four reads collapse into one.
That sounds like a performance footnote, but it’s more than that, because right next to React.cache lives a tool that looks similar and does real damage here if you grab the wrong one. This confusion is the one to watch most closely, so the next two variants put the two tools side by side.
import { cache } from 'react';
const getSession = cache(async () => auth.api.getSession({ headers: await headers() }),);Request-scoped, and that’s exactly right. React.cache dedupes the read within one request and throws the result away when the request ends. The next request, possibly from a different user, starts clean and reads its own cookie. The session is request data, so it belongs in a request-scoped cache.
const getSession = async () => { 'use cache'; return auth.api.getSession({ headers: await headers() });};This serves one user’s session to another. 'use cache' persists across requests and across users. Its cache key is built from the function’s arguments and captured values, and the cookie isn’t one of them, so user B can be handed the entry computed for user A. That’s not a slow page; it’s an account-takeover bug. Per-user session data must never go in 'use cache'.
Hold onto the distinction, because you’ll meet it constantly: request-scoped caching (React.cache) versus cross-request caching ('use cache'). The course’s caching conventions state it flatly. React cache() is for request-scoped memoization of work that depends on request data; 'use cache' is for cross-request persistence; they are different tools. The rule that follows from that is firm: never capture request-scoped data inside a 'use cache' function. A session read depends entirely on the request’s cookie, so it lives in React.cache, every time.
The helpers everyone calls: getCurrentUser and requireUser
Section titled “The helpers everyone calls: getCurrentUser and requireUser”Everything so far converges into two exports. You’ve seen the call, you’ve seen that every surface needs it, and you’ve seen that it must be wrapped in React.cache. Rather than have every page, layout, action, and route handler rewrite all of that, and risk one of them forgetting the cache wrapper, the await, or the headers(), you write it once in lib/auth.ts and everyone imports it.
Two helpers cover every case from the surfaces section:
getCurrentUser(): Promise<User | null>is the safe read. It returnsnullfor an anonymous request. Reach for it whenever a surface renders one way signed-in and another way signed-out.requireUser(next?): Promise<User>is the assertive read. It returns theUser, or redirects to/sign-inwhen there’s no session, so the code after the call can treat the user as guaranteed. Reach for it on protected pages and actions. The optionalnextparameter is the path to come back to after signing in, so a bounced user lands where they were headed.
Here’s the file. It’s short, but every line earns its place.
import { headers } from 'next/headers';import { redirect } from 'next/navigation';import { cache } from 'react';
type User = typeof auth.$Infer.Session.user;
const getSession = cache(async () => auth.api.getSession({ headers: await headers() }),);
export const getCurrentUser = async (): Promise<User | null> => { const session = await getSession(); return session?.user ?? null;};
/** * Returns the user if the session is valid; redirects to `/sign-in` otherwise. * * @param next - The path to return to after sign-in. */export const requireUser = async (next?: string): Promise<User> => { const user = await getCurrentUser(); if (!user) { redirect(next ? `/sign-in?next=${encodeURIComponent(next)}` : '/sign-in'); } return user;};The new imports are cache from react, redirect from next/navigation, and headers from next/headers. They join the server-only import and the auth instance already at the top of this file. User is derived straight from the instance via auth.$Infer.Session.user, so the helper’s type tracks your schema with no hand-written interface.
import { headers } from 'next/headers';import { redirect } from 'next/navigation';import { cache } from 'react';
type User = typeof auth.$Infer.Session.user;
const getSession = cache(async () => auth.api.getSession({ headers: await headers() }),);
export const getCurrentUser = async (): Promise<User | null> => { const session = await getSession(); return session?.user ?? null;};
/** * Returns the user if the session is valid; redirects to `/sign-in` otherwise. * * @param next - The path to return to after sign-in. */export const requireUser = async (next?: string): Promise<User> => { const user = await getCurrentUser(); if (!user) { redirect(next ? `/sign-in?next=${encodeURIComponent(next)}` : '/sign-in'); } return user;};The private, request-cached core. It’s the one place auth.api.getSession({ headers: await headers() }) is ever called. Both public helpers go through it, so the underlying read runs once per request no matter how many components ask.
import { headers } from 'next/headers';import { redirect } from 'next/navigation';import { cache } from 'react';
type User = typeof auth.$Infer.Session.user;
const getSession = cache(async () => auth.api.getSession({ headers: await headers() }),);
export const getCurrentUser = async (): Promise<User | null> => { const session = await getSession(); return session?.user ?? null;};
/** * Returns the user if the session is valid; redirects to `/sign-in` otherwise. * * @param next - The path to return to after sign-in. */export const requireUser = async (next?: string): Promise<User> => { const user = await getCurrentUser(); if (!user) { redirect(next ? `/sign-in?next=${encodeURIComponent(next)}` : '/sign-in'); } return user;};The safe read. session?.user ?? null flattens { user, session } | null down to User | null, so callers get the user or a clean null, never the session wrapper.
import { headers } from 'next/headers';import { redirect } from 'next/navigation';import { cache } from 'react';
type User = typeof auth.$Infer.Session.user;
const getSession = cache(async () => auth.api.getSession({ headers: await headers() }),);
export const getCurrentUser = async (): Promise<User | null> => { const session = await getSession(); return session?.user ?? null;};
/** * Returns the user if the session is valid; redirects to `/sign-in` otherwise. * * @param next - The path to return to after sign-in. */export const requireUser = async (next?: string): Promise<User> => { const user = await getCurrentUser(); if (!user) { redirect(next ? `/sign-in?next=${encodeURIComponent(next)}` : '/sign-in'); } return user;};The assertive read. On null it never returns: redirect throws to the framework, which sends the browser to /sign-in. Everything after this line can treat user as present, with no extra check.
import { headers } from 'next/headers';import { redirect } from 'next/navigation';import { cache } from 'react';
type User = typeof auth.$Infer.Session.user;
const getSession = cache(async () => auth.api.getSession({ headers: await headers() }),);
export const getCurrentUser = async (): Promise<User | null> => { const session = await getSession(); return session?.user ?? null;};
/** * Returns the user if the session is valid; redirects to `/sign-in` otherwise. * * @param next - The path to return to after sign-in. */export const requireUser = async (next?: string): Promise<User> => { const user = await getCurrentUser(); if (!user) { redirect(next ? `/sign-in?next=${encodeURIComponent(next)}` : '/sign-in'); } return user;};The next thread. Pass the current path in and the sign-in page can send the user back where they came from. encodeURIComponent keeps a path with query params from corrupting the redirect URL.
This is the shape to internalize. There is exactly one place in your entire codebase that calls auth.api.getSession directly, and it’s that private getSession inside this file. Never call it from a page, layout, action, or route handler; always go through getCurrentUser or requireUser. The reason is the cache wrapper. Call the raw API from a component and you’ve opted out of the per-request dedupe, so you’re back to four reads. The helper carries the wrapping; the raw call doesn’t. The course’s auth conventions put it plainly: no parallel getSession calls anywhere.
A third helper, requireOrgUser(role?), joins these two later when you add the organizations plugin. It answers a richer question, “who is this and what may they do in their org?”, and it’s where authorization finally enters the picture. You’re not building it here, since there’s no org plugin yet; just recognize the name so it’s familiar when it arrives.
This shape should feel familiar. It’s the same move as lib/db.ts from the chapter on Drizzle: a thin, domain-shaped wrapper named once and called everywhere, so the rest of the app speaks your vocabulary instead of the library’s. getCurrentUser and requireUser are that wrapper for identity.
The proxy gate: cookie-presence, not a session read
Section titled “The proxy gate: cookie-presence, not a session read”There’s a sixth place that cares about the session, and it’s deliberately different from the other five. Before /dashboard renders anything, you want to bounce a signed-out visitor to /sign-in, cheaply, before the layout and its database reads even start. That’s the job of proxy.ts, the file that runs on every request its matcher selects, before the route does. (This is the middleware-to-proxy rename you met in the chapter on the App Router.)
State the key rule up front so it doesn’t surprise you: the proxy bounces signed-out visitors; it does not validate the session, and it does not authorize anything. Real validation is the layout’s requireUser(). The proxy is an optimistic, fast redirect, like a bouncer who checks whether you’re holding a ticket rather than whether the ticket is genuine. The genuine check happens inside.
That distinction drives the code. The proxy doesn’t call getSession at all; it only checks whether a session cookie is present.
import { getSessionCookie } from 'better-auth/cookies';import { NextResponse, type NextRequest } from 'next/server';
import { SESSION_COOKIE_PREFIX } from '@/lib/auth';
export const proxy = (request: NextRequest) => { const sessionCookie = getSessionCookie(request, { cookiePrefix: SESSION_COOKIE_PREFIX, });
if (!sessionCookie) { return NextResponse.redirect(new URL('/sign-in', request.url)); }
return NextResponse.next();};
export const config = { matcher: ['/dashboard/:path*'],};getSessionCookie comes from better-auth/cookies, and SESSION_COOKIE_PREFIX is the constant the previous lesson exported from lib/auth.ts. It’s imported here so the proxy reads the same cookie name the instance writes.
import { getSessionCookie } from 'better-auth/cookies';import { NextResponse, type NextRequest } from 'next/server';
import { SESSION_COOKIE_PREFIX } from '@/lib/auth';
export const proxy = (request: NextRequest) => { const sessionCookie = getSessionCookie(request, { cookiePrefix: SESSION_COOKIE_PREFIX, });
if (!sessionCookie) { return NextResponse.redirect(new URL('/sign-in', request.url)); }
return NextResponse.next();};
export const config = { matcher: ['/dashboard/:path*'],};The function Next.js runs before a matched route renders. The proxy name is the framework contract, renamed from middleware in Next.js 16. It runs on the Node runtime rather than the edge, so a real getSession read would technically be possible here. You still won’t do it; the reasons are below.
import { getSessionCookie } from 'better-auth/cookies';import { NextResponse, type NextRequest } from 'next/server';
import { SESSION_COOKIE_PREFIX } from '@/lib/auth';
export const proxy = (request: NextRequest) => { const sessionCookie = getSessionCookie(request, { cookiePrefix: SESSION_COOKIE_PREFIX, });
if (!sessionCookie) { return NextResponse.redirect(new URL('/sign-in', request.url)); }
return NextResponse.next();};
export const config = { matcher: ['/dashboard/:path*'],};Cookie-presence only. This checks whether a session cookie exists under the configured prefix; it does not decode it, validate it, or hit the database. Passing SESSION_COOKIE_PREFIX is the detail everything hinges on, because without it this defaults to the wrong prefix.
import { getSessionCookie } from 'better-auth/cookies';import { NextResponse, type NextRequest } from 'next/server';
import { SESSION_COOKIE_PREFIX } from '@/lib/auth';
export const proxy = (request: NextRequest) => { const sessionCookie = getSessionCookie(request, { cookiePrefix: SESSION_COOKIE_PREFIX, });
if (!sessionCookie) { return NextResponse.redirect(new URL('/sign-in', request.url)); }
return NextResponse.next();};
export const config = { matcher: ['/dashboard/:path*'],};No cookie on a matched route means bounce to /sign-in. request.url gives the redirect an absolute base to resolve against.
import { getSessionCookie } from 'better-auth/cookies';import { NextResponse, type NextRequest } from 'next/server';
import { SESSION_COOKIE_PREFIX } from '@/lib/auth';
export const proxy = (request: NextRequest) => { const sessionCookie = getSessionCookie(request, { cookiePrefix: SESSION_COOKIE_PREFIX, });
if (!sessionCookie) { return NextResponse.redirect(new URL('/sign-in', request.url)); }
return NextResponse.next();};
export const config = { matcher: ['/dashboard/:path*'],};A cookie is present, so let the request through. The real check still waits inside, in the layout’s requireUser(); the proxy has only confirmed the visitor is holding something.
import { getSessionCookie } from 'better-auth/cookies';import { NextResponse, type NextRequest } from 'next/server';
import { SESSION_COOKIE_PREFIX } from '@/lib/auth';
export const proxy = (request: NextRequest) => { const sessionCookie = getSessionCookie(request, { cookiePrefix: SESSION_COOKIE_PREFIX, });
if (!sessionCookie) { return NextResponse.redirect(new URL('/sign-in', request.url)); }
return NextResponse.next();};
export const config = { matcher: ['/dashboard/:path*'],};The matcher decides which requests run the proxy at all. '/dashboard/:path*' covers the dashboard and everything beneath it; the full protected surface gets mapped out in a later chapter.
That raises the obvious question: why getSessionCookie here and not the auth.api.getSession you’ve used everywhere else? The Node-runtime proxy in Next.js 16 could run the full read, since the old edge-runtime limitation that forced this pattern in earlier versions is gone. So this is a deliberate choice rather than a workaround, and there are two reasons behind it.
The first is about where security decisions belong. Authorization is re-checked against the database at the action boundary, never in the proxy. That’s a rule from the course’s auth conventions, and it exists because the cookie cache from the previous lesson means the proxy can read a stale session for a few minutes after something changes. A user you just revoked could still be holding a cookie that looks fine to a presence check. If the proxy were your security boundary, that staleness window would be a hole. Since it’s only an optimistic redirect for UX, the window is harmless, because the real check downstream catches the revocation. Better Auth’s own docs say the same thing: getSessionCookie is not secure on its own, so validate in the page.
The second reason is a production bug waiting to happen, and it’s why SESSION_COOKIE_PREFIX exists at all. getSessionCookie defaults to the better-auth prefix and silently misses any cookie set under a different one. Recall what the previous lesson did: in production it sets the cookie under the __Host-better-auth prefix. If you hardcoded the default here instead of importing the constant, the cookie would exist, the presence check would look for the wrong name, find nothing, and bounce a perfectly signed-in user to /sign-in, in production only, because dev uses the plain prefix. That’s the canonical version of this bug: a __Host- prefix in the cookie config and a hardcoded default in the proxy, drifting apart across environments. Importing the one exported constant is what keeps the read and the write locked to the same name everywhere.
Client reads are for display, server reads are for decisions
Section titled “Client reads are for display, server reads are for decisions”That fourth surface, the Client Component, has been waiting, and it’s where the most common real-world auth mistake lives.
On the browser, Better Auth gives you a hook, authClient.useSession(). It returns { data, isPending, error }, it’s reactive, and it refetches when the tab regains focus. It’s the right tool for an avatar in the header, a “signed in as Ada” line, or any chrome that should quietly update when the session changes. Use it freely for that.
But there is a hard line, and it’s the whole point of this section: client reads drive UI; server reads drive decisions. The value useSession hands you can be stale, and it can be forged, since anyone can open devtools and rewrite what their JavaScript believes about who they are. So it’s fine for deciding what to show. It is never the thing you gate a mutation or a protected render on. The truth is the server read, auth.api.getSession through your helpers, and only the server read.
You couldn’t break this rule even if you tried to call the server API from a Client Component, for two reasons that both echo the server/client split from the first lesson of this chapter. First, reaching for auth in client code imports a server-only module, which fails the build on purpose. Second, even if it somehow didn’t, the call needs the request’s cookies, and a browser doesn’t pass its own cookies into its own JavaScript that way. The split is enforced by construction. On the browser you use authClient.useSession() to observe the session; on the server you use auth.api.getSession with await headers() to read it for decisions. Same library, opposite sides of the wire.
One pattern catches people: gating UI inside a useEffect that reads the session client-side. It feels like protecting the page, but it isn’t. It shows a flicker of protected content before the effect runs, backed by a value the user can fake. The gate is the server’s job, through requireUser in the layout or the proxy’s cheap bounce, and the client read is for display only.
The right server read hands a Client Component a trimmed user, not the whole session, and that’s easiest to see as the data actually travels. Scrub through the trace below: the page reads the user on the server and passes data down to a Client Component, and you can watch what crosses the wire.
DashboardPage reads the session on the server via getCurrentUser, the one
place this read belongs.
Both props serialize and cross. { id, name } is exactly what UserMenu
needs. The session token also crosses, because nothing stops a serializable
value, and that’s the leak: serializable does not mean safe to send.
The wire panel shows the rule in miniature: serializable is not the same as safe. The session token would cross the wire without complaint and sit in your client bundle for anyone to read. What you hand a Client Component is a deliberately trimmed shape, { id, name }, the fields the UI needs, never the session row and never the token.
Check your understanding
Section titled “Check your understanding”Seven situations need to know who the user is, and three answers cover them: a server read through your helpers, a client read with useSession, or a cheap cookie-presence check in the proxy. Sort each situation into the layer that should handle it. Watch the last one, because deciding whether someone is allowed to do something is always a decision, and decisions live on the server.
Sort each situation into the layer that should answer it. Drag each item into the bucket it belongs to, then press Check.
/dashboard rendersIf the last chip pulled you toward the client because the delete button lives in the browser, that’s the instinct this lesson exists to correct. Where the button is and where the decision is made are different questions. The button can be a Client Component, but whether the click is allowed to delete anything is decided on the server, against the real session, every time.
External resources
Section titled “External resources”A few references worth a bookmark: Better Auth’s own guidance, the Next.js authentication guide that mirrors this lesson’s per-surface pattern, and the React cache reference behind the request-scoped read.
Session reads on the server, and why the proxy does cookie-presence gating rather than full validation.
The official per-surface playbook: optimistic proxy checks, a DAL wrapped in React cache, and trimming what crosses to the client.
The async request API this lesson's read path passes into auth.api.getSession.
Request-scoped memoization: why one getSession dedupes across every component in a render.