The Problem
I shipped a login flow on a Next.js 16.2 client project last Friday. Server Action signs the user in, calls cookies().set('session', token), then redirect('/dashboard'). Worked fine in dev. Pushed to Vercel preview, got the same dashboard load, but the next request had no session cookie. Login screen reappeared. The action ran cleanly with no error, no warning, no nothing. The cookie just never made it to the browser.
If you upgraded from Next.js 15 and your auth, locale, or feature-flag cookies stopped persisting after a redirect() in a Server Action, this is a Next.js 16 behavior change in how Set-Cookie headers attach to redirect responses. Three shapes of the bug, three fixes.
Why It Happens
In Next.js 15, cookies() returned a synchronous proxy. You could call cookies().set(...) immediately before redirect(...) and the framework would attach the resulting Set-Cookie header to the 307 redirect response. The browser saw the redirect, set the cookie, then followed.
In Next.js 16, cookies() is async. You must await it. redirect() still throws to bail out of the action, but the cookie store now resolves through the async runtime. The framework attaches pending writes to the redirect response only if the cookie was set on the resolved store, not on the unresolved proxy.
Three things commonly break this.
Thing 1: Forgetting await. This compiles cleanly if strict cookie types are off. cookies().set('x', 'y') returns a promise that schedules the write, but the action throws into redirect before the microtask runs and the cookie never lands.
Thing 2: A try/catch that swallows redirect(). redirect() throws an internal NEXT_REDIRECT error. If you wrap the cookie write in a try/catch and log without re-throwing, you swallow the redirect signal, the action returns null, and useFormState stays on the same route.
Thing 3: A proxy.ts rewriting the redirect. Next.js 16 replaced middleware.ts with proxy.ts. If your proxy intercepts the redirect target (an auth proxy rewriting /dashboard for unauthenticated users), the follow-up request can run before the browser commits the cookie. You end up in a redirect loop back to the login page.
The Fix
Step 1: Always await cookies(). In any Server Action or Route Handler that sets cookies before redirecting, the canonical shape is:
'use server'
import { cookies } from 'next/headers'
import { redirect } from 'next/navigation'
export async function loginAction(_: unknown, formData: FormData) {
const token = await signIn(formData.get('email'), formData.get('password'))
if (!token) return { error: 'Invalid credentials' }
const cookieStore = await cookies()
cookieStore.set({
name: 'session',
value: token,
httpOnly: true,
secure: true,
sameSite: 'lax',
path: '/',
maxAge: 60 * 60 * 24 * 7,
})
redirect('/dashboard')
}
The object form is what the cookie store persists synchronously into the response writer. The two-argument set('session', token) works, but on edge runtime the inferred path is sometimes '', which the browser silently rejects on the redirected URL.
Step 2: Never wrap redirect() in a try/catch. If you absolutely need a try/catch around the action body for logging, re-throw the redirect explicitly:
import { isRedirectError } from 'next/dist/client/components/redirect'
try {
await doWork()
redirect('/done')
} catch (err) {
if (isRedirectError(err)) throw err
console.error(err)
return { error: 'Server error' }
}
This is the only safe pattern. Swallowing the redirect error breaks every cookie write that depended on it.
Step 3: Audit your proxy.ts for cookie-dependent rewrites. If your proxy reads the session cookie and rewrites unauthenticated traffic, make sure the rewrite ignores the redirect target of your login flow:
// app/proxy.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function proxy(request: NextRequest) {
const session = request.cookies.get('session')
// Skip auth check on the dashboard's first hit after login.
const isLoginRedirect = request.headers.get('referer')?.includes('/login')
if (!session && !isLoginRedirect && request.nextUrl.pathname.startsWith('/dashboard')) {
return NextResponse.redirect(new URL('/login', request.url))
}
return NextResponse.next()
}
export const config = {
matcher: ['/dashboard/:path*'],
}
The referer check stops the redirect-loop trap while the browser commits the cookie. For higher-stakes flows, set a short-lived auth_intent cookie alongside session and check that in the proxy.
Step 4: Confirm in the Network tab. Submit the form and look at the action's response (a 303 in 16, was 307 in 15). You must see Set-Cookie: session=... on the 303 itself, not on a follow-up request. If it is missing, your action is the problem. If it is present but the next request has no Cookie: session=..., your proxy or domain config is stripping it.
The Next.js cookies docs cover the async API change in detail and are worth a fresh read after the 16 upgrade.
The Lesson
The Next.js 16 async cookies API is not just a TypeScript change. Any auth, locale, or A/B test flow that mixes cookie writes with redirect() needs to be re-audited. Awaiting the store and using the object form of set() is what gets the cookie onto the actual redirect response.
If your auth flow broke after the 16 upgrade and you cannot pin down whether it is the action or the proxy, I debug this kind of thing on retainer — see my services. For a related upgrade gotcha I covered earlier this month, the Next.js 16 proxy.ts not running fix walks through the matcher config that catches a lot of teams off guard.