Skip to content
Chapter 52Lesson 4

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.

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.

1 / 1

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 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.

the incoming request carries the session cookie — or not
proxy cookie-presence only · never this call
one call auth.api.getSession({ headers }) → { user, session } | null
on null, each site differs
layout / Server Component redirect
Server Action unauthorized Result
route handler 401
client (via a prop) gets the user, never asks

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.

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.

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.

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, or null for 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: the User, or a redirect to /sign-in when there’s no session, so code after the call treats the user as guaranteed. Use it on protected pages and actions. The optional next is 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.

1 / 1

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.

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.

1 / 1

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.

What crosses to the client?

Hand a Client Component a deliberately trimmed { id, name }, never the session row or the token.

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.

Server read auth.api.getSession via getCurrentUser / requireUser
Client read authClient.useSession() — display only
Proxy gate getSessionCookie — presence only
Show the user’s avatar in a header Client Component
Refuse an anonymous mutation in a Server Action
Bounce a signed-out visitor before /dashboard renders
Hide an admin link in a layout
Show “signed in as X” chrome that updates when the tab regains focus
Return a 401 from a route handler
Decide whether a user may delete an invoice

If 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.

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.