Reading the session everywhere
One way to read the Better Auth session in every server context, wrapped in getCurrentUser and requireUser, plus a cheap cookie-presence gate in the proxy.
Picture the dashboard you’re about to build. The header shows the signed-in user’s avatar and name, the body renders their invoices, and the sidebar hides an admin link unless this user is an admin. Three places on one screen, one request, all asking: who is this?
The question outlives rendering.
When the user clicks “delete invoice,” the Server Action behind it must identify the user before writing anything, and refuse an anonymous request.
A route handler answering a mobile app needs the same answer.
And before /dashboard renders at all, a signed-out visitor should be bounced cheaply, without real work.
Earlier lessons gave you sessions that persist, so the question isn’t how to read one. It’s this: what is the one way to read “who is this user?” across every server context, and where may each context legitimately diverge?
The answer is a single call shape wrapped in two helpers you reach for instead of the raw call.
By the end you’ll have a getCurrentUser() / requireUser() pair in lib/auth.ts, plus a minimal proxy.ts gate, both built on the session lifetimes and cookie cache you configured last lesson.
The one call: auth.api.getSession
Section titled “The one call: auth.api.getSession”Every surface in this lesson is built on the same two lines:
import { headers } from 'next/headers';
import { auth } from '@/lib/auth';
const session = await auth.api.getSession({ headers: await headers() });A Server Component won’t hand the auth library a cookie store, so you pass the incoming request’s headers and Better Auth reads the Cookie off them. In Next.js 16 headers() is async, so await headers() is the canonical form. Drop the await and you pass a Promise where a Headers is expected, 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 this chapter’s first lesson. It runs in-process: no network hop, 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: the typed user row and the typed session row, which carries expiresAt, ipAddress, and userAgent.
The return type is Promise<{ user, session } | null>, and the null is the part to watch.
A null is not an error; the request carries no valid session cookie, so the user is anonymous.
Every surface here differs only in how it reacts to that null; the read itself never changes.
You turned on the cookie cache in the previous lesson, so this call reads the signed …session_data cookie when the cache is fresh and falls through to the database otherwise.
The call shape is identical either way: you never branch on whether the cache is warm.
Under the hood the library resolves the opaque session token , but at this layer that work is invisible.
Five surfaces, one call, different tails
Section titled “Five surfaces, one call, different tails”Five surfaces need to know who the user is: layouts and Server Components, Server Actions, route handlers, Client Components, and the proxy.
Four make the same getSession call and differ only in how they handle null.
The proxy never makes the call, so it gets its own section.
auth.api.getSession({ headers }) → { user, session } | null null, each site differs The proxy peels off before the call; the other four make the identical read and differ only on a null result.
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. On null it renders the signed-out variant, or, when the whole subtree demands a session like everything under /dashboard, calls requireUser to redirect. The read opts the subtree into dynamic rendering, which is intended. 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 top, before any write. On null, return the unauthorized discriminant of the Result type as err('unauthorized', …), a typed refusal rather than a thrown error. This reads who the user is; deciding whether they may act, by role or org, layers on later in the authedAction wrapper.
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 makes the identical read. On null it returns a 401 in the Problem Details shape: no identity on the request, said in a status code a machine client can act on.
The fourth surface, a Client Component, never makes an auth call itself. You read the session on the server, in the layout or page that renders it, and pass the user down as a prop, so the client receives the answer rather than asking for it.
One call, 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.
Read once per request with React.cache
Section titled “Read once per request with React.cache”In that dashboard the session gets read several times per request: the layout reads it for the shell, the header for the avatar, the page body to scope the data, maybe the sidebar too. Four reads of the same thing, each one paying the cookie-cache decode or hitting the database.
React’s cache() memoizes a function for the duration of one request: the first call runs the work, and every later call anywhere in the tree gets the same resolved Promise.
Four reads collapse into one.
A neighboring tool looks similar and does real damage if you grab it by mistake.
import { cache } from 'react';
const getSession = cache(async () => auth.api.getSession({ headers: await headers() }),);Request-scoped, exactly right. React.cache dedupes the read within one request and discards it when the request ends. The next request, possibly 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 users. Its key comes from the function’s arguments and captured values, not the cookie, so user B can be handed the entry computed for user A. That’s not a slow page, it’s an account-takeover bug.
You’ll meet this distinction constantly: request-scoped caching with React.cache versus cross-request caching with 'use cache'.
A session read depends entirely on the request’s cookie, so it lives in React.cache, every time.
The session helpers: getCurrentUser and requireUser
Section titled “The session helpers: getCurrentUser and requireUser”Every page, layout, action, and route handler needs the same session read: auth.api.getSession, wrapped in React.cache, with await and headers().
Write it once in lib/auth.ts and import it everywhere, so no surface forgets a piece.
Two helpers cover every case from the surfaces section:
getCurrentUser(): Promise<User | null>is the safe read: the user, ornullfor an anonymous request. Use it when a surface renders one way signed-in and another way signed-out.requireUser(next?): Promise<User>is the assertive read: theUser, or a redirect to/sign-inwhen there’s no session, so code after the call treats the user as guaranteed. Use it on protected pages and actions. The optionalnextis the path to return to after sign-in, so a bounced user lands where they were headed.
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 cache from react, redirect from next/navigation, and headers from next/headers join the server-only import and auth instance already at the top. User is derived via auth.$Infer.Session.user, so the 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: the one place auth.api.getSession is ever called. Both public helpers go through it, so the 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.
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. Code after this line can treat user as present.
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 sends the user back. encodeURIComponent keeps a path with query params from corrupting the redirect URL.
That private getSession is the only place that calls auth.api.getSession directly.
Always go through getCurrentUser or requireUser; only the helper carries the cache wrapper, so a raw call opts out of the per-request dedupe and you’re back to four reads.
A third helper, requireOrgUser(role?), joins these when the organizations plugin adds authorization later; just recognize the name for now.
This is the same move as lib/db.ts from the Drizzle chapter: a thin, domain-shaped wrapper, named once and called everywhere, that lets the app speak your vocabulary instead of the library’s.
The proxy gate: cookie-presence, not a session read
Section titled “The proxy gate: cookie-presence, not a session read”A sixth place cares about the session, and it works unlike the other five.
Before /dashboard renders, you want to bounce a signed-out visitor to /sign-in cheaply, before the layout’s database reads begin.
That is proxy.ts, which runs before the route on every request its matcher selects.
The key rule: 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 redirect, like a bouncer checking whether you hold a ticket, not whether the ticket is genuine.
So it never calls getSession; 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. SESSION_COOKIE_PREFIX is the constant the previous lesson exported from lib/auth.ts, imported 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, named proxy by contract (renamed from middleware in Next.js 16). It runs on the Node runtime, so a real getSession read is possible here; the reasons not to 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, without decoding it, validating it, or hitting the database. Everything hinges on passing SESSION_COOKIE_PREFIX; 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.
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 requireUser().
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. '/dashboard/:path*' covers the dashboard and everything beneath it.
Why getSessionCookie here, and not the auth.api.getSession you use everywhere else?
The Node-runtime proxy could run the full read, so this is deliberate, for two reasons.
First, security decisions belong at the action boundary, re-checked against the database. The cookie cache from the previous lesson lets the proxy read a stale session for a few minutes: a user you just revoked still holds a cookie that passes a presence check. As a security boundary that window is a hole; as an optimistic redirect it is harmless, because the downstream check catches the revocation.
Second, getSessionCookie defaults to the better-auth prefix and silently misses a cookie set under a different one.
Production stores the cookie under __Host-better-auth, so hardcoding the default would find nothing and bounce a signed-in user to /sign-in in production only, while dev uses the plain prefix.
Importing the one exported constant locks the read and the write to the same name across environments.
Client reads are for display, server reads are for decisions
Section titled “Client reads are for display, server reads are for decisions”The fourth surface, the Client Component, is where the most common auth mistake lives.
On the browser, Better Auth gives you a reactive hook, authClient.useSession(), returning { data, isPending, error }.
It’s the right tool for chrome that should update quietly: an avatar, a “signed in as Ada” line.
But hold a hard line: 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.
Use it to decide what to show, never to gate a mutation or a protected render.
The truth is the server read, auth.api.getSession through your helpers.
You couldn’t break this rule even by trying to call the server API from a Client Component.
Reaching for auth in client code imports a server-only module that fails the build on purpose; and the call needs the request’s cookies, which a browser doesn’t hand to its own JavaScript.
So on the browser you observe with authClient.useSession(), and on the server you read for decisions with auth.api.getSession and await headers().
One mistake is common: gating UI inside a useEffect that reads the session client-side.
It feels like protecting the page, but it flashes protected content before the effect runs, backed by a value the user can fake.
Gating is the server’s job, through requireUser in the layout or the proxy’s cheap bounce; the client read is for display only.
The right server read hands a Client Component a trimmed user, not the whole session. Scrub through the trace to 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. UserMenu needs { id, name }; the session
token crosses too, because nothing stops a serializable value.
Serializable does not mean safe to send.
Hand a Client Component a deliberately trimmed { id, name }, never the session row or the token.
Check your understanding
Section titled “Check your understanding”Three answers cover every situation below: 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 answer it. Drag each item into the bucket it belongs to, then press Check.
/dashboard rendersIf the delete-invoice chip pulled you toward the client, separate where the button is from where the decision is made: the button can be a Client Component, but whether a click may delete anything is decided on the server, against the real session, every time.
External resources
Section titled “External resources”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.