Route Used cookies(). cookies Should Be Awaited: The Next.js 15 Upgrade That Looked Like Broken Auth
After a Next 14→15 bump a root layout that called auth() and cookies().get threw Route used cookies(). cookies should be awaited, then a TypeError that looked like login was down. The session cookie was fine. cookies(), headers(), params, and searchParams are Promises now. This is the field note for awaiting them in Server Components, layouts, Route Handlers, generateMetadata, and the Auth.js helper that hid the miss.
Route Used cookies(). cookies Should Be Awaited: The Next.js 15 Upgrade That Looked Like Broken Auth
The most expensive upgrade error is the one that files itself under the wrong product. We bumped a Coolify-hosted Next.js 14 App Router app to 15 on a quiet afternoon — the kind of Swiss SME stack we already run for products like Acurio — and the first request after the new container came up green threw **Route "/" used cookies(). \cookies` should be awaited**. The root layout called auth()to paint a session chip. A helper one import away still didcookies().get('authjs.session-token') the Next 14 way. A second wrap, meant to keep the layout synchronous, surfaced as **TypeError: cookies is not a function** and, on the next refresh, **params should be awaited**. Support opened it as "login is down after the deploy." The session cookie was still in the jar. The Promise was not unwrapped. This is the writeup of **Next.js 15 async dynamic APIs** — cookies(), headers(), params, and searchParamsas Promises — and why a missingawait` in a parent layout or Auth.js helper looks like an auth bug, not a type error.
The Auth.js passwordChangedAt note already taught us that session failures love to wear a costume. That one was a dropped custom claim. This one is a dropped unwrap. Same chip in the layout. Different layer.
The Sync Call That Compiled and Then Exploded
Next 15 made the request-scoped APIs asynchronous so the runtime can start rendering a tree before the request data is ready. cookies(), headers(), and draftMode() from next/headers return Promises. So do the params and searchParams props on pages, layouts, Route Handlers, and generateMetadata. Next 15 still had a synchronous fallback that warned. Turn the warning into an error, or land on a later 15 patch that enforces it, and the fallback is gone. The line that "always worked" is now a runtime throw.
The types are the second costume. A page still typed as { params: { id: string } } will let you write params.id. At runtime params is a Promise. params.id is undefined. Destructure { params: { id } } in the function signature and you are reading properties off the Promise object, not the route. The build can stay green if @types or the Next package in that lockfile still advertise the old shape. Production is the first place the string params should be awaited shows up.
The helper is the third costume. The revalidateTag writeup still has the Next 14 shape in its origin-log example — headers().get('x-request-id') — because that note is about cache layers, not this upgrade. Copy that line into a 15 layout and you have this incident. The official warning is Dynamic APIs are Asynchronous. The codemod (npx @next/codemod@canary next-async-request-api .) rewrites the call sites it can see. It does not follow getSessionCookie() into a lib/ file, and it does not make a sync layout async for you. Those are the @next-codemod-error comments people delete.
// Next 14 muscle memory. Compiles. Throws on 15.
export function getSessionCookie() {
return cookies().get("authjs.session-token")?.value;
}
export default function RootLayout({
children,
params,
}: {
children: React.ReactNode;
params: { locale: string };
}) {
const session = getSessionCookie();
const { locale } = params;
return (
<html lang={locale}>
<body>
<SessionChip token={session} />
{children}
</body>
</html>
);
}
cookies() now returns a Promise. Calling .get on that Promise is how you get cookies is not a function or .get is not a function, depending on whether someone destructured, rebound the import, or treated the Promise as the store. params.locale is how you get a layout that renders lang="[object Promise]" for an hour before the stricter error lands. Neither string says "Auth.js." Both land on the first authenticated paint.
Await at the Boundary — Then Pass Plain Values In
The fix is boring, and it has to happen at every boundary that used to read the request synchronously.
Server Components and layouts. Make the function async. Await the store, then read. Await params before you destructure. auth() from Auth.js is already async; it must be awaited at the call site too, even though the failure you see is usually the cookies() inside it, not auth itself.
import { cookies, headers } from "next/headers";
import { auth } from "@/auth";
export async function getSessionCookie() {
const store = await cookies();
return store.get("authjs.session-token")?.value;
}
export default async function RootLayout({
children,
params,
}: {
children: React.ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
const session = await auth();
const requestId = (await headers()).get("x-request-id");
return (
<html lang={locale}>
<body data-request-id={requestId ?? undefined}>
<SessionChip user={session?.user} />
{children}
</body>
</html>
);
}
Await at the route boundary. Pass plain strings and objects inward. A helper that accepts params: Promise<{ id: string }> and awaits internally is legal; a helper that accepts { id: string } and is handed the unresolved Promise is the next ticket. We prefer the second signature and one await at the page.
Pages and searchParams. Same Promise. searchParams.q on 15 is not the query string. It is a property that does not exist on a Promise.
export default async function SearchPage({
searchParams,
}: {
searchParams: Promise<{ q?: string }>;
}) {
const { q } = await searchParams;
return <Results query={q ?? ""} />;
}
Route Handlers. The second argument's params is a Promise. The Request is still a Request. Do not "await the request" because you heard everything is async now.
export async function GET(
_req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const store = await cookies();
if (!store.get("authjs.session-token")) {
return new Response("unauthorized", { status: 401 });
}
return Response.json({ id });
}
generateMetadata. Same props, same Promises, easier to miss because the function is not the page you click. Title and Open Graph that still read params.slug ship undefined into <title> and look like a CMS miss.
export async function generateMetadata({
params,
searchParams,
}: {
params: Promise<{ id: string }>;
searchParams: Promise<{ lang?: string }>;
}) {
const { id } = await params;
const { lang } = await searchParams;
const product = await getProduct(id);
return { title: product?.name ?? id, alternates: { languages: { [lang ?? "en"]: `/${id}` } } };
}
sitemap.ts / robots.ts. These helpers do not get params, but they do get headers() if you build an absolute URL from the host. const h = headers(); h.get("host") is the same throw, filed as "sitemap 500 after the upgrade." Await the store, or pass the host in from headers() at a parent that already awaited.
Client Components cannot await. Read params / searchParams on the server and pass values down, or unwrap with React.use(params) inside a 'use client' file. Do not put React.use in a Server Component. That is a different error and a different hour.
The Auth.js Helper That Was Not an Auth Bug
Auth.js / NextAuth auth() reads the session cookie through cookies() under the hood. So do the wrappers teams write — getServerSession leftovers, currentUser(), a lib/session.ts that calls cookies().get "because we only need the token." After 15, a missing await in the parent is enough. The layout does not have to call cookies() itself. It calls getSession(), which calls auth(), which calls cookies(), and the stack that lands in the overlay is cookies should be awaited with a frame inside next-auth. That is why the ticket says "Auth.js broke on Next 15." Auth.js did not change the cookie name. The runtime changed the return type of the function Auth.js has to call.
The confusing TypeError is the one people patch in the wrong file. They bump next-auth, rotate AUTH_SECRET, re-read the JWT passwordChangedAt check, and open a thread about session invalidation. The layout is still sync. The helper is still cookies().get. The session in DevTools is valid. Middleware that uses the Auth.js wrapper can throw on the same path if that wrapper was not updated — and middleware is the worst place to diagnose it, because the error is "the whole site redirects to /login," which is also what a real session failure looks like.
Rule we now grep for after a 15 bump: every cookies(, headers(, draftMode(, and every params. / searchParams. read, including inside auth wrappers, generateMetadata, sitemap, robots, and Route Handlers. Then change the types to Promise<…> so the next sync read is a compile error, not a Friday overlay.
The Checklist We Run After the Overlay Says "cookies"
Four checks, in this order, before anyone is allowed to call it an Auth.js outage.
Read the exact string. Route "…" used cookies(). \cookies` should be awaitedandparams should be awaitedare the Next 15 contract.cookies is not a function/.get is not a functionis usually a Promise treated as the store, or a shadowedcookiesbinding.unauthorized/ bounce to/loginwith a valid session cookie is often the layout or middleware helper, not a revoked JWT. If the cookie is present andiat` is fresh, stop debugging claims and start debugging awaits.
Await at the boundary, type the Promise. const store = await cookies(), const { id } = await params, const { q } = await searchParams, const h = await headers(). Update the prop type to Promise<{ … }>. Leave a sync ({ params: { id } }) destructure in the signature and you have not migrated; you have hidden id.
Follow the helpers the codemod cannot see. lib/session.ts, currentUser(), getToken(), anything that wraps auth() or cookies(). Make those functions async, await inside, await at every caller. A sync layout that "just needs the chip" is the incident. Soft-navigating into a page whose layout still sync-reads params is how only some routes throw.
Do not skip metadata and the XML routes. generateMetadata, sitemap.ts, robots.ts, and route.ts share the same APIs and do not show up in the page you clicked. A 500 on /sitemap.xml after a 15 bump is this bug until proven otherwise.
Three Rules That Survive the Next Runtime Flag
Three rules survive this writeup and generalise past whatever Next calls a dynamic API in the next major.
A dynamic API is a Promise, even when last year's types say it is not. cookies(), headers(), draftMode(), params, and searchParams unwrap with await on the server and React.use on the client. Sync access is not "fine until you need streaming." It is a warning that becomes a throw. The fallback is not a feature you should keep.
The failure wears the name of the helper, not the API. auth() throwing cookies should be awaited is not an Auth.js regression. A layout throwing params should be awaited is not a routing bug. A sitemap 500 is not "SEO broke." Grep the wrapper. Await the store. Then look at session invalidation if the cookie is actually gone.
Codemod the call sites you can see; audit the ones you cannot. The official codemod is the right first move. The @next-codemod-error comments it leaves in sync helpers are the real work. Delete them without making the function async and you have scheduled the overlay for the first authenticated layout paint.
The composition is the note. We treated a Next 15 bump as an Auth.js outage because the stack frame sat inside auth() and the chip in the root layout went blank. Production was a Promise from cookies() read as a store, and a params object that was no longer an object. One await at the layout boundary, Promise types on the props, and a grep that includes helpers would have shown the hole in the first minute — the same minute we spent rotating secrets.
If you are mid-upgrade on an App Router app and the first authenticated layout throws cookies should be awaited — book a free AI Potenzial-Check. The Auth.js JWT writeup is the session-claim half of the same "the costume is not the layer" lesson; the revalidateTag note is the reminder that headers() in a render log has to come from the same request, and on 15 that means awaiting it first.
acurio · Hallucinated citations? Not in your manuscript.
Citation checker for Zotero. Finds hallucinated or partially supported sources in AI‑written text. Thesis packages from CHF 19, Swiss data processing.