Session Management

Authentication proves who someone is at one moment — the login request. A session is how that identity is remembered on every request that follows, without asking the user to log in again each time. In a Next.js App Router app, sessions are almost always persisted in a cookie, because cookies are automatically sent by the browser with every request, including the very first server-rendered request for a page.

Cookie attributes that matter

Whatever the session actually contains, the cookie carrying it should be set with:



JWT sessions vs. opaque, server-stored sessions

There are two common shapes for what actually goes inside the cookie:

Many production setups use a hybrid: a short-lived JWT for fast, stateless checks (e.g. in Middleware), backed by a server-side session record that's consulted before any sensitive action.



Reading and writing session cookies

The cookies() function from next/headers is how you read and write cookies on the server. Its capabilities depend on where you call it: inside a Server Component you can only read cookies (the response has already started streaming, so there's nowhere to attach a Set-Cookie header); inside a Server Action or a Route Handler you can both read and write, because Next.js can still modify the outgoing response.

Creating and destroying a session from a Server Action. This example seals the session payload before storing it in the cookie, so the pattern applies whether that seal is a JWT or your own signed/encrypted blob.
lib,session.ts:
import { cookies } from 'next/headers'
import { SignJWT, jwtVerify } from 'jose'

const secret = new TextEncoder().encode(process.env.SESSION_SECRET)
const SESSION_MAX_AGE = 60 * 60 * 24 * 7 // 7 days, in seconds

export async function createSession(userId: string) {
  const expires = new Date(Date.now() + SESSION_MAX_AGE * 1000)
  const token = await new SignJWT({ userId })
    .setProtectedHeader({ alg: 'HS256' })
    .setExpirationTime(expires)
    .sign(secret)

  ;(await cookies()).set('session', token, {
    httpOnly: true,
    secure: true,
    sameSite: 'lax',
    expires,
    path: '/',
  })
}

export async function decryptSession(token: string) {
  try {
    const { payload } = await jwtVerify(token, secret)
    return payload as { userId: string }
  } catch {
    return null // missing, expired, or tampered with
  }
}

export async function destroySession() {
  (await cookies()).delete('session')
}
Refreshing a session on activity ("sliding expiration") from a Route Handler. Extending expires on each authenticated request keeps active users signed in while letting idle sessions lapse.
app,api,session,refresh,route.ts:
import { cookies } from 'next/headers'
import { NextResponse } from 'next/server'
import { decryptSession, createSession } from '@/lib/session'

export async function POST() {
  const token = (await cookies()).get('session')?.value
  const session = token ? await decryptSession(token) : null

  if (!session) {
    return NextResponse.json({ error: 'No active session.' }, { status: 401 })
  }

  await createSession(session.userId) // re-issues the cookie with a fresh expiry
  return NextResponse.json({ ok: true })
}

Whichever storage shape you pick, treat the session secret used to sign or encrypt the cookie the same way you'd treat a database password: kept in an environment variable, never committed, and rotated if it's ever exposed. Session management libraries like iron-session wrap this exact sealing/unsealing logic so you don't have to write it by hand.