Web Development Bug Report

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.

Daniel Tran Daniel Tran @fun_dev · November 08, 2024 · 9 min read · 12 Views
Banner: Next.js 15 cookies() should be awaited before using its value

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

  1. Check out the upgrade branch and run next dev.
  2. Open a private window with no session cookie.
  3. 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.ts only 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 GET route handlers and client router navigations by default. We suspected a cached response from a logged-in session, but the response header was Cache-Control: private, no-cache, no-store and 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 .js files to .tsx first. This is what really protects you, because the types for params and cookies() 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-apis warnings, down from 41.
  • next build finished with no type errors after enabling checkJs for app/.
  • In production logs, anonymous requests to /invoices/export returned 307 to /login instead of 200. We checked over 24 hours: 112 anonymous hits, 112 redirects.
  • The 401 rate on /api/invoices from 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: true for the app/ and src/lib/ folders, plus @typescript-eslint/no-floating-promises and @typescript-eslint/no-misused-promises in ESLint. The second rule flags a Promise used in a condition, which is exactly if (!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-api codemod and resolve every @next-codemod-error comment by hand.
  • Treat sync-dynamic-apis warnings 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 null for callers to check.
  • Test protected routes against a production build, anonymously, in CI.

References

Add a label

#nextjs #react #app-router #typescript #codemod
Written by
Daniel Tran @fun_dev

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.

Advertisement

Comments

0

No comments yet. Be the first to comment!

Sign In to post a comment.