Next.js8 min read
Next.js Authentication Guide: Sessions, proxy.ts, DAL
Next.js authentication guide for Next.js 16: pick an auth library, manage sessions, protect routes with proxy.ts and secure Server Components and Actions.
Next.js authentication in the App Router has three layers: a library or custom code that signs users in and stores a session (usually in an HTTP-only cookie), an optional proxy.ts that redirects signed-out users early, and a Data Access Layer that verifies the session on the server before any data is read or changed. The proxy is a convenience. The Data Access Layer is the real security boundary.
This guide covers the current options in Next.js 16.4: which libraries to look at, how sessions work, how to protect routes with the new proxy.ts convention, and how to check auth in Server Components, Server Actions and Route Handlers.
Key takeaways
- Use a library unless you have a strong reason not to. The official docs list Auth0, Better Auth, Clerk, Descope, Kinde, Logto, NextAuth.js, Ory, Stack Auth, Supabase, Stytch and WorkOS.
middleware.tsis deprecated in Next.js 16. Useproxy.tswith aproxyexport. It runs on the Node.js runtime.- Proxy checks should only read the cookie. Do database checks in a Data Access Layer.
- Do not put auth checks in layouts. They do not re-run on navigation and do not stop nested routes or actions from running.
- Treat every Server Action and Route Handler as a public endpoint and verify the session inside it.
- With Cache Components, session reads go behind
<Suspense>, and per-user data is cached by passing the user ID into'use cache'.
Choosing a Next.js authentication library in 2026
| Option | Type | Good fit when |
|---|---|---|
| Clerk, Auth0, WorkOS, Kinde, Stytch, Descope | Managed service | You want hosted UI, MFA and SSO without running it yourself |
| Supabase Auth | Managed, tied to Supabase | You already use Supabase for the database |
| Better Auth | Self-hosted library | You want users in your own database with a TypeScript-first API |
Auth.js (next-auth@beta) |
Self-hosted library | You already use it and want to stay on it |
jose or iron-session plus your own code |
DIY | Simple apps, learning, or very custom requirements |
One status note: the Auth.js documentation now states that the Auth.js project is part of Better Auth, and it links to a migration guide. Auth.js v5 is still installed through the beta tag. It works with Next.js 16, but for a new self-hosted project I would evaluate Better Auth first.
Session management in Next.js: stateless vs database sessions
There are two ways to keep a user signed in:
- Stateless (JWT in a cookie). The session is signed or encrypted and stored entirely in the cookie. No database lookup on each request, but you cannot revoke a single session easily.
- Database sessions. The cookie holds a random or encrypted session ID that points to a row. You can list devices and sign users out remotely, at the cost of a lookup.
Whichever you choose, set the cookie on the server with safe options:
// lib/session.ts
import 'server-only'
import { cookies } from 'next/headers'
import { SignJWT, jwtVerify } from 'jose'
const key = new TextEncoder().encode(process.env.SESSION_SECRET)
export type SessionPayload = { userId: string; role: 'admin' | 'member'; expiresAt: Date }
export async function encrypt(payload: SessionPayload) {
return new SignJWT({ ...payload, expiresAt: payload.expiresAt.toISOString() })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('7d')
.sign(key)
}
export async function decrypt(token: string | undefined) {
if (!token) return null
try {
const { payload } = await jwtVerify(token, key, { algorithms: ['HS256'] })
return payload as unknown as { userId: string; role: 'admin' | 'member' }
} catch {
return null
}
}
export async function createSession(userId: string, role: SessionPayload['role']) {
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
const token = await encrypt({ userId, role, expiresAt })
const cookieStore = await cookies()
cookieStore.set('session', token, {
httpOnly: true,
secure: true,
sameSite: 'lax',
path: '/',
expires: expiresAt,
})
}Generate the secret with openssl rand -base64 32. Keep the payload small: an ID and a role, never an email, phone number or password hash.
Setting up Better Auth or Auth.js with Next.js 16
Better Auth
// lib/auth.ts
import { betterAuth } from 'better-auth'
import { nextCookies } from 'better-auth/next-js'
export const auth = betterAuth({
// database: your adapter or connection
emailAndPassword: { enabled: true },
plugins: [nextCookies()], // keep this last so actions can set cookies
})// app/api/auth/[...all]/route.ts
import { auth } from '@/lib/auth'
import { toNextJsHandler } from 'better-auth/next-js'
export const { GET, POST } = toNextJsHandler(auth)Reading the session on the server:
import { headers } from 'next/headers'
import { auth } from '@/lib/auth'
const session = await auth.api.getSession({ headers: await headers() })Auth.js v5
npm install next-auth@beta
npx auth secret// auth.ts
import NextAuth from 'next-auth'
import GitHub from 'next-auth/providers/github'
export const { handlers, auth, signIn, signOut } = NextAuth({
providers: [GitHub],
})// app/api/auth/[...nextauth]/route.ts
import { handlers } from '@/auth'
export const { GET, POST } = handlersOn the server, const session = await auth() returns the session or null.
Protecting routes with proxy.ts (formerly middleware)
Next.js 16 renamed Middleware to Proxy. Rename middleware.ts to proxy.ts and the exported function to proxy. Proxy now runs on the Node.js runtime, so most auth libraries work there.
Proxy runs on every matched request, including prefetches. Keep it to an optimistic cookie check:
// proxy.ts
import { NextResponse, type NextRequest } from 'next/server'
import { decrypt } from '@/lib/session'
const protectedPrefixes = ['/dashboard', '/settings']
export async function proxy(request: NextRequest) {
const { pathname } = request.nextUrl
const isProtected = protectedPrefixes.some((p) => pathname.startsWith(p))
if (!isProtected) return NextResponse.next()
const session = await decrypt(request.cookies.get('session')?.value)
if (!session?.userId) {
const url = new URL('/login', request.url)
url.searchParams.set('next', pathname)
return NextResponse.redirect(url)
}
return NextResponse.next()
}
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
}With Auth.js, the shortcut is export { auth as proxy } from '@/auth' plus an authorized callback in auth.ts. With Better Auth, getSessionCookie(request) from better-auth/cookies gives a cookie-only check. Better Auth's own docs label that check insecure on its own, and they are right: it only proves a cookie exists.
Creating a Data Access Layer for authorization
The Data Access Layer (DAL) is a server-only module that every data read and write goes through. It verifies the session first, then returns only the fields the caller needs:
// lib/dal.ts
import 'server-only'
import { cache } from 'react'
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
import { decrypt } from './session'
export const verifySession = cache(async () => {
const session = await decrypt((await cookies()).get('session')?.value)
if (!session?.userId) redirect('/login')
return { userId: session.userId, role: session.role }
})
export async function getInvoices() {
const { userId } = await verifySession()
return db.invoice.findMany({
where: { ownerId: userId },
select: { id: true, total: true, status: true }, // no internal fields
})
}React's cache deduplicates verifySession within one render. It does nothing inside Server Actions or Route Handlers, which are not render passes, so the check simply runs each time there. That is fine.
Auth in Server Components, Server Actions and Route Handlers
Server Components: call the DAL in the page or in the component that renders sensitive UI.
// app/dashboard/page.tsx
import { Suspense } from 'react'
import { getInvoices } from '@/lib/dal'
export default function DashboardPage() {
return (
<main>
<h1>Dashboard</h1>
<Suspense fallback={<p>Loading invoices...</p>}>
<Invoices />
</Suspense>
</main>
)
}
async function Invoices() {
const invoices = await getInvoices()
return <ul>{invoices.map((i) => <li key={i.id}>{i.total}</li>)}</ul>
}The Suspense boundary matters with Cache Components: reading cookies outside one blocks the static shell, and the dev overlay will flag it. The <h1> stays in the prerendered shell while the invoices stream in.
Server Actions: verify inside every action, and filter writes by owner.
'use server'
import { verifySession } from '@/lib/dal'
export async function deleteInvoice(id: string) {
const { userId, role } = await verifySession()
if (role !== 'admin') throw new Error('Forbidden')
await db.invoice.deleteMany({ where: { id, ownerId: userId } })
}Route Handlers: return status codes instead of redirecting.
// app/api/invoices/route.ts
import { cookies } from 'next/headers'
import { decrypt } from '@/lib/session'
export async function GET() {
const session = await decrypt((await cookies()).get('session')?.value)
if (!session) return new Response(null, { status: 401 })
const invoices = await db.invoice.findMany({ where: { ownerId: session.userId } })
return Response.json(invoices)
}More on securing mutations is in the Next.js Server Actions guide.
Authentication with Cache Components
When cacheComponents is on, a few extra rules apply:
- A session read is request-time, so it cannot be in the static shell. Put components that read it behind
<Suspense>. - Plain
'use cache'cannot callcookies(). Read the user ID outside and pass it in, so the cache key is per user. - The docs suggest
'use cache: private'for agetCurrentUser()helper. It can read cookies directly and caches the result only in the browser. - Invalidate per-user data with
updateTag(`notes:${userId}`)from a Server Action. - Keep cached functions that take a
userIdunexported, so other code cannot ask for another user's data by passing a different ID.
The caching side of this is explained in Next.js caching explained.
Common Next.js authentication mistakes
- Relying only on proxy. Proxy can be misconfigured by a matcher change, and it does not run for code paths you forgot. Check in the DAL too.
- Checking auth in a layout and returning
null. Nested pages, parallel routes and actions still run. Check in pages, components or the DAL. - Accepting
userIdfrom the form. Always derive identity from the session. - Database calls in proxy. Proxy runs on every matched request including prefetches. Keep it to cookie checks.
- Leaking full user records to Client Components. Return a narrow object. React's
taintUniqueValuecan stop specific secrets from being passed to the client. - Long-lived JWTs with no rotation. Set a sensible expiry and refresh the cookie when the user returns.
FAQ
What is the best authentication library for Next.js?
There is no single best one. Managed services such as Clerk, Auth0 or WorkOS save the most time. Better Auth is a popular self-hosted option, and the Auth.js project is now part of Better Auth. Pick based on whether you want to own user data and how much you want to pay.
Is middleware still used for auth in Next.js 16?
middleware.ts is deprecated in Next.js 16 and replaced by proxy.ts with a proxy export that runs on the Node.js runtime. Use it only for fast, optimistic redirects based on the session cookie, never as your only authorization check.
Where should I check authentication in the App Router?
As close to the data as possible: in a Data Access Layer function that every query, Server Action and Route Handler calls. Proxy and layout checks are useful for redirects and UX, but they do not protect data on their own.
Why shouldn't I do auth checks in a layout?
Layouts do not re-render on client navigation, and they do not stop nested pages, parallel routes or Server Actions from running. A user could reach protected data through another entry point, so check in the page, the component or the data layer.
Is Auth.js v5 stable?
As of October 2026, the Auth.js docs still install it with next-auth@beta and do not label it stable. The project has joined Better Auth, which publishes a migration guide, so new projects should weigh Better Auth as well.
Wrapping up
Solid Next.js authentication is less about the library and more about where the checks live. Let a library handle sign-in and sessions, use proxy.ts for quick redirects, and make a server-only Data Access Layer the one place that decides who can read or change data. The official Next.js authentication guide and the Cache Components auth guide are the best references for the details.
- #nextjs
- #authentication
- #security
- #app-router
Keep reading
Next.js8 min read
Next.js App Router vs Pages Router: Migration Guide
Next.js App Router vs Pages Router explained: the real differences, when to migrate, and a step-by-step migration guide updated for Next.js 16.4.
Read articleNext.js9 min read
Next.js i18n Guide: App Router, Routing and hreflang
Next.js internationalization with the App Router: locale routing in Proxy, server-side dictionaries, hreflang and a localized sitemap, with English and Nepali.
Read articleNext.js7 min read
Next.js SEO Guide: Metadata, Sitemap, JSON-LD, OG
A practical Next.js SEO guide for Next.js 16: Metadata API, generateMetadata, canonical URLs, sitemap.ts, robots.ts, JSON-LD and dynamic OG images.
Read article