Skip to content

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.ts is deprecated in Next.js 16. Use proxy.ts with a proxy export. 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:

TypeScript
// 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

TypeScript
// 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
})
TypeScript
// 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:

TypeScript
import { headers } from 'next/headers'
import { auth } from '@/lib/auth'

const session = await auth.api.getSession({ headers: await headers() })

Auth.js v5

Terminal
npm install next-auth@beta
npx auth secret
TypeScript
// auth.ts
import NextAuth from 'next-auth'
import GitHub from 'next-auth/providers/github'

export const { handlers, auth, signIn, signOut } = NextAuth({
  providers: [GitHub],
})
TypeScript
// app/api/auth/[...nextauth]/route.ts
import { handlers } from '@/auth'

export const { GET, POST } = handlers

On 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:

TypeScript
// 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:

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

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

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

TypeScript
// 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 call cookies(). Read the user ID outside and pass it in, so the cache key is per user.
  • The docs suggest 'use cache: private' for a getCurrentUser() 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 userId unexported, 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 userId from 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 taintUniqueValue can 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

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

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