Next.js 15 upgrade: cookies() should be awaited before using its value
After upgrading to Next.js 15 our dev console filled with sync dynamic API warnings, and one un-awaited auth helper quietly stopped redirecting logged-out users. How we migrated cookies(), headers() and params safely.
Two weeks after Next.js 15 went stable we upgraded our internal billing dashboard from 14.2 to 15.0.2.
The build passed, the smoke tests passed, and the dev server printed a wall of yellow warnings that
everyone agreed to "clean up later". Three days later a QA engineer noticed that opening
/invoices/export in a private window no longer bounced her to the login page. It rendered an empty
export screen instead.
The dev server output for that route looked like this:
Error: Route "/invoices/export" used `cookies().get('session')`. `cookies()` should be awaited before using its value. Learn more: https://nextjs.org/docs/messages/sync-dynamic-apis
at getSessionToken (src/lib/auth.ts:9:26)
at requireUser (src/lib/auth.ts:17:17)
There were 41 similar lines across the app, for cookies(), headers() and for params in pages
and layouts. Most of them were noise. One of them was hiding a real authentication regression.
Reproducing it
- Check out the upgrade branch and run
next dev. - Open a private window with no
sessioncookie. - Visit
/invoices/export.
Expected: a redirect to /login. Actual: HTTP 200 with an empty table. The API calls behind the
table returned 401, so no invoice data leaked, but the page-level guard was clearly not running.
What we ruled out
- Middleware. Our
middleware.tsonly handles locale prefixes. It never checked auth, so it did not change behaviour between 14 and 15. - The caching default change. Next.js 15 stopped caching
GETroute handlers and client router navigations by default. We suspected a cached response from a logged-in session, but the response header wasCache-Control: private, no-cache, no-storeand the page was dynamic in both versions. - The session cookie itself. In the logged-in case
cookies().get('session')still returned the right value. The warning was annoying but the value was correct.
Root cause
We had run the official codemod, which correctly rewrote getSessionToken() in auth.ts to
await cookies() and made it async. That turned requireUser() into an async function too. The
codemod updated callers in .tsx files, but the export page lived in an old page.js file that was
not type checked. That page still did const user = requireUser(); without await. user was now
a Promise, a Promise is always truthy, so if (!user) redirect('/login') never fired.
What is happening under the hood
Next.js 15 changed the request-scoped APIs to be asynchronous: cookies(), headers() and
draftMode() from next/headers now return Promises, and the params and searchParams props of
pages, layouts, route handlers and generateMetadata are Promises as well. The motivation is
rendering: if a component does not need request data, Next.js can start rendering it before the
request is fully available, and only the parts that await request data become dynamic.
To make upgrades survivable, 15.x keeps a temporary compatibility layer. The returned Promise also
exposes the old synchronous properties, so cookies().get('x') still works but logs the
sync-dynamic-apis warning in development. TypeScript types, however, are already the new async
ones, which is why typed files fail to compile while untyped .js files happily run.
That compatibility layer is what made our bug so quiet:
| Code | Next.js 14 | Next.js 15 |
|---|---|---|
cookies().get('session') |
value | value plus dev warning |
await cookies() then .get('session') |
works (await on non-Promise) | value, no warning |
requireUser() after it became async |
object or null |
always a Promise |
if (!requireUser()) |
correct check | never true |
The framework did nothing wrong. Our helper's contract changed from sync to async, and one caller did not notice.
Environment
| Component | Version / value |
|---|---|
| OS | macOS 15.1 (dev), Debian 12 container (prod) |
| Runtime | Node.js 20.18.0 |
| Framework | Next.js 15.0.2, App Router |
| React | 19.0.0-rc as bundled by Next.js 15 |
| TypeScript | 5.6.3, allowJs: true, checkJs: false |
| Codemod | @next/codemod canary, next-async-request-api transform |
Minimal reproduction
// src/lib/auth.ts (after the codemod)
import { cookies } from 'next/headers';
export async function getSessionToken() {
return (await cookies()).get('session')?.value ?? null;
}
export async function requireUser() {
const token = await getSessionToken();
return token ? await loadUser(token) : null;
}
// app/invoices/export/page.js (untouched legacy file)
import { redirect } from 'next/navigation';
import { requireUser } from '@/lib/auth';
export default function ExportPage() {
const user = requireUser(); // Promise, not a user
if (!user) redirect('/login'); // never runs
return <ExportTable ownerId={user.id} />; // user.id is undefined
}
Who is affected
Anyone upgrading an App Router project to Next.js 15 who has helper functions wrapping cookies(),
headers() or params, especially in mixed JavaScript and TypeScript codebases. Pages Router
projects are not affected because getServerSideProps receives req directly.
The fix
We split the migration into three passes so nothing slipped through again.
Pass 1: run the codemod and read its leftovers.
npx @next/codemod@canary next-async-request-api .
grep -rn "@next-codemod-error" src app
The codemod leaves a comment starting with @next-codemod-error wherever it could not safely
rewrite a call. Next.js turns those comments into errors in dev and build, so they cannot be
ignored by accident. We had six; each one became a manual change.
Pass 2: make every page that reads request data async and await at the top.
- export default function ExportPage() {
- const user = requireUser();
+ export default async function ExportPage() {
+ const user = await requireUser();
if (!user) redirect('/login');
return <ExportTable ownerId={user.id} />;
}
For dynamic segments we typed params as a Promise and awaited it once:
// app/invoices/[invoiceId]/page.tsx
type Props = { params: Promise<{ invoiceId: string }> };
export default async function InvoicePage({ params }: Props) {
const { invoiceId } = await params;
const invoice = await getInvoice(invoiceId);
return <InvoiceView invoice={invoice} />;
}
export async function generateMetadata({ params }: Props) {
const { invoiceId } = await params;
return { title: `Invoice ${invoiceId}` };
}
In the two Client Components that received params from a parent we used React.use(params)
instead of await, since Client Components cannot be async.
Pass 3: make auth fail closed. A guard that can be defeated by a missing await is a weak guard.
We changed requireUser() to redirect by itself instead of returning null for callers to check:
// src/lib/auth.ts
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
export async function requireUser(): Promise<User> {
const token = (await cookies()).get('session')?.value;
const user = token ? await loadUser(token) : null;
if (!user) redirect('/login');
return user;
}
Now a caller that forgets await gets a Promise and immediately crashes on user.id, which shows
up in tests, instead of silently rendering a page.
Alternatives and trade-offs
- Leave sync access in place for now. It works in 15.x with warnings, but the compatibility layer is documented as temporary. Every warning is future breakage, and the noise hides real issues like ours.
- Convert all
.jsfiles to.tsxfirst. This is what really protects you, because the types forparamsandcookies()are already async. We did it for the auth and billing folders and scheduled the rest. - Read cookies in middleware and pass a header down. Useful for very hot paths, but it moves auth decisions away from the data and is easy to bypass with a crafted header if you do not strip it at the edge. We did not go this way.
How to test the fix
We added a Playwright test that runs against next build && next start, not the dev server, so the
production path is exercised:
// e2e/auth-guard.spec.ts
import { test, expect } from '@playwright/test';
const guarded = ['/invoices', '/invoices/export', '/settings/billing'];
for (const path of guarded) {
test(`${path} redirects anonymous users`, async ({ page }) => {
const res = await page.goto(path);
expect(page.url()).toContain('/login');
expect(res?.status()).toBeLessThan(400);
});
}
npx next build && npx next start -p 3100 &
PLAYWRIGHT_BASE_URL=http://localhost:3100 npx playwright test e2e/auth-guard.spec.ts
Before the fix the /invoices/export case failed; after it all 3 passed.
Confirming in production
- The dev server started with zero
sync-dynamic-apiswarnings, down from 41. next buildfinished with no type errors after enablingcheckJsforapp/.- In production logs, anonymous requests to
/invoices/exportreturned307to/logininstead of200. We checked over 24 hours: 112 anonymous hits, 112 redirects. - The 401 rate on
/api/invoicesfrom browser sessions dropped from roughly 3 percent of requests to near zero, because the page no longer rendered and fired API calls for logged-out users.
Regression guard
checkJs: truefor theapp/andsrc/lib/folders, plus@typescript-eslint/no-floating-promisesand@typescript-eslint/no-misused-promisesin ESLint. The second rule flags a Promise used in a condition, which is exactlyif (!requireUser()).- The Playwright guard test runs on every pull request with a list of protected routes generated
from the
app/folder.
// eslint.config.mjs (excerpt)
{
files: ['app/**/*.{ts,tsx,js,jsx}', 'src/**/*.{ts,tsx}'],
rules: {
'@typescript-eslint/no-floating-promises': 'error',
'@typescript-eslint/no-misused-promises': ['error', { checksVoidReturn: false }],
},
}
Security implications
This was an authorization bug at the page level. Our API routes enforce auth independently, so the empty page never received invoice data, but in an app where Server Components query the database directly, the same mistake would render another user's data or a default account. The general lesson: any guard returning a value the caller must check can be defeated by a sync/async contract change. Prefer guards that redirect or throw themselves, and keep authorization checks next to the data access, not only in the page.
Prevention checklist
- Run the
next-async-request-apicodemod and resolve every@next-codemod-errorcomment by hand. - Treat
sync-dynamic-apiswarnings as failures during an upgrade, not cosmetic noise. - Type check JavaScript files that touch auth, or convert them to TypeScript first.
- Make auth helpers fail closed: redirect or throw, never return
nullfor callers to check. - Test protected routes against a production build, anonymously, in CI.
References
- Next.js 15 release blog — https://nextjs.org/blog/next-15
- Next.js upgrade guide to version 15 — https://nextjs.org/docs/app/guides/upgrading/version-15
- Next.js message: Dynamic APIs are Asynchronous — https://nextjs.org/docs/messages/sync-dynamic-apis
- Next.js codemods:
next-async-request-api— https://nextjs.org/docs/app/guides/upgrading/codemods - typescript-eslint
no-misused-promises— https://typescript-eslint.io/rules/no-misused-promises/
Add a label
Embed this post
<iframe src="https://hibug.me/nextjs-15-cookies-should-be-awaited" width="700" height="420" frameborder="0" loading="lazy"></iframe>
I have spent most of my career on the side of software that pages people at night: payment integrations, web platforms with real traffic, CI/CD pipelines, Kubernetes clusters and the databases underneath them. Somewhere along the way I started keeping a notebook of every bug that cost more than an hour — the exact error, what we ruled out, the real root cause and how we proved the fix. Hibug is where those notes live. Every post follows the same path: Investigate, Explain, Fix, Verify — with the environment spelled out, the fix tested, and the security angle checked.
Related Stories
Vite import.meta.env variable undefined in the production Docker build
Comments
0No comments yet. Be the first to comment!
Sign In to post a comment.