Zum Inhalt springen
tecminds

Route used cookies() — cookies sollte awaited werden: Das Next.js-15-Upgrade, das nach kaputter Auth aussah

Nach einem Next-14→15-Bump hat ein Root-Layout, das auth() und cookies().get aufgerufen hat, Route used cookies(). cookies should be awaited geworfen, dann einen TypeError, der nach ausgefallenem Login aussah. Das Session-Cookie war in Ordnung. cookies(), headers(), params und searchParams sind jetzt Promises. Das ist die Feldnotiz dafür, sie in Server Components, Layouts, Route Handlers, generateMetadata und dem Auth.js-Helper zu awaiten, der das Fehlen versteckt hat.

TTobias LüscherCo‑Founder · TecMinds2026-09-08 · 10 Min Lesezeit

Route used cookies() — cookies sollte awaited werden: Das Next.js-15-Upgrade, das nach kaputter Auth aussah

Der teuerste Upgrade-Fehler ist der, der sich selbst ins falsche Produkt einsortiert. Wir haben an einem ruhigen Nachmittag eine Coolify-gehostete Next.js-14-App-Router-App auf 15 gehoben — denselben Stack, den wir für Schweizer-KMU-Produkte wie Acurio fahren — und der erste Request, nachdem der neue Container grün war, warf **Route "/" used cookies(). \cookies` should be awaited**. Das Root-Layout rief auth()auf, um einen Session-Chip zu zeichnen. Ein Helper einen Import weiter machte immer nochcookies().get('authjs.session-token') auf Next-14-Art. Ein zweiter Wrap, gedacht damit das Layout synchron bleiben kann, tauchte als **TypeError: cookies is not a function** auf und, beim nächsten Refresh, als **params should be awaited**. Support hat es als "Login ist nach dem Deploy down" eröffnet. Das Session-Cookie lag noch im Jar. Das Promise war nicht unwrapped. Das ist die Aufzeichnung der **asynchronen Dynamic APIs in Next.js 15** — cookies(), headers(), paramsundsearchParamsals Promises — und warum ein fehlendesawait` in einem Parent-Layout oder Auth.js-Helper nach einem Auth-Bug aussieht, nicht nach einem Typfehler.

Die Auth.js-passwordChangedAt-Notiz hat uns schon beigebracht, dass Session-Fehler Kostüme tragen. Damals war es ein gedroppter Custom-Claim. Hier ist es ein gedropptes Unwrap. Derselbe Chip im Layout. Eine andere Schicht.

Der synchrone Aufruf, der kompiliert hat und dann explodiert ist

Next 15 hat die request-scoped APIs asynchron gemacht, damit die Runtime einen Tree rendern kann, bevor die Request-Daten bereit sind. cookies(), headers() und draftMode() aus next/headers geben Promises zurück. Ebenso die params- und searchParams-Props auf Pages, Layouts, Route Handlers und generateMetadata. Next 15 hatte noch einen synchronen Fallback, der gewarnt hat. Drehst du die Warning auf Error oder landest auf einem späteren 15-Patch, der das durchsetzt, ist der Fallback weg. Die Zeile, die "immer ging", ist jetzt ein Runtime-Throw.

Die Types sind das zweite Kostüm. Eine Page, die noch als { params: { id: string } } typisiert ist, lässt dich params.id schreiben. Zur Laufzeit ist params ein Promise. params.id ist undefined. Destrukturierst du { params: { id } } in der Funktionssignatur, liest du Properties vom Promise-Objekt, nicht von der Route. Der Build kann grün bleiben, wenn @types oder das Next-Paket in diesem Lockfile noch die alte Form bewerben. Produktion ist der erste Ort, an dem der String params should be awaited auftaucht.

Der Helper ist das dritte Kostüm. Die revalidateTag-Aufzeichnung hat in ihrem Origin-Log-Beispiel noch die Next-14-Form — headers().get('x-request-id') — weil diese Notiz Cache-Schichten betrifft, nicht dieses Upgrade. Kopierst du diese Zeile in ein 15-Layout, hast du diesen Vorfall. Die offizielle Warning ist Dynamic APIs are Asynchronous. Der Codemod (npx @next/codemod@canary next-async-request-api .) schreibt die Call-Sites um, die er sehen kann. Er folgt getSessionCookie() nicht in eine lib/-Datei, und er macht ein sync-Layout nicht für dich async. Das sind die @next-codemod-error-Kommentare, die Leute löschen.

// Next-14-Muskelgedächtnis. Kompiliert. Wirft auf 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() gibt jetzt ein Promise zurück. .get auf diesem Promise aufzurufen ist, wie du cookies is not a function oder .get is not a function bekommst — je nachdem, ob jemand destrukturiert, den Import rebound oder das Promise als Store behandelt hat. params.locale ist, wie du ein Layout bekommst, das eine Stunde lang="[object Promise]" rendert, bevor der strengere Fehler landet. Keiner der Strings sagt "Auth.js." Beide landen auf dem ersten authentifizierten Paint.

Am Boundary awaiten — dann nackte Werte nach innen reichen

Der Fix ist langweilig, und er muss an jeder Boundary passieren, die den Request früher synchron gelesen hat.

Server Components und Layouts. Mach die Funktion async. Await den Store, dann lies. Await params, bevor du destrukturierst. auth() von Auth.js ist schon async; es muss an der Call-Site ebenfalls awaited werden, auch wenn der Fehler, den du siehst, meist das cookies() darin ist, nicht auth selbst.

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 an der Route-Boundary. Reich nackte Strings und Objekte nach innen. Ein Helper, der params: Promise<{ id: string }> akzeptiert und intern awaitet, ist legal; ein Helper, der { id: string } akzeptiert und das unaufgelöste Promise gereicht bekommt, ist das nächste Ticket. Wir bevorzugen die zweite Signatur und ein await an der Page.

Pages und searchParams. Dasselbe Promise. searchParams.q auf 15 ist nicht der Query-String. Es ist eine Property, die auf einem Promise nicht existiert.

export default async function SearchPage({
  searchParams,
}: {
  searchParams: Promise<{ q?: string }>;
}) {
  const { q } = await searchParams;
  return <Results query={q ?? ""} />;
}

Route Handlers. Das params des zweiten Arguments ist ein Promise. Der Request bleibt ein Request. Den Request nicht "awaiten", weil du gehört hast, jetzt sei alles async.

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. Dieselben Props, dieselben Promises, leichter zu übersehen, weil die Funktion nicht die Page ist, die du klickst. Title und Open Graph, die noch params.slug lesen, schiffen undefined in <title> und sehen nach einem CMS-Miss aus.

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. Diese Helper bekommen kein params, aber sie bekommen headers(), wenn du eine absolute URL aus dem Host baust. const h = headers(); h.get("host") ist derselbe Throw, abgelegt als "Sitemap-500 nach dem Upgrade." Await den Store, oder reich den Host von einem Parent herein, der schon awaited hat.

Client Components können nicht awaiten. Lies params / searchParams auf dem Server und reich Werte nach unten, oder unwrap mit React.use(params) in einer 'use client'-Datei. Leg React.use nicht in eine Server Component. Das ist ein anderer Fehler und eine andere Stunde.

Der Auth.js-Helper, der kein Auth-Bug war

Auth.js / NextAuth-auth() liest das Session-Cookie unter der Haube über cookies(). Ebenso die Wrapper, die Teams schreiben — getServerSession-Reste, currentUser(), ein lib/session.ts, das cookies().get aufruft, "weil wir nur den Token brauchen." Nach 15 reicht ein fehlendes Await im Parent. Das Layout muss cookies() nicht selbst aufrufen. Es ruft getSession() auf, das auth() aufruft, das cookies() aufruft, und der Stack, der im Overlay landet, ist cookies should be awaited mit einem Frame in next-auth. Deshalb sagt das Ticket "Auth.js ist auf Next 15 kaputt." Auth.js hat den Cookie-Namen nicht geändert. Die Runtime hat den Rückgabetyp der Funktion geändert, die Auth.js aufrufen muss.

Der verwirrende TypeError ist der, den Leute in der falschen Datei patchen. Sie bumpen next-auth, rotieren AUTH_SECRET, lesen den JWT-passwordChangedAt-Check nochmal und eröffnen einen Thread über Session-Invalidierung. Das Layout ist immer noch sync. Der Helper ist immer noch cookies().get. Die Session in DevTools ist gültig. Middleware, die den Auth.js-Wrapper nutzt, kann auf demselben Pfad werfen, wenn der Wrapper nicht nachgezogen wurde — und Middleware ist der schlechteste Ort, das zu diagnostizieren, weil der Fehler "die ganze Site redirected auf /login" ist, was auch ein echter Session-Fehler ist.

Die Regel, nach der wir nach einem 15-Bump grepen: jedes cookies(, headers(, draftMode( und jeder params.- / searchParams.-Read, inklusive in auth-Wrappern, generateMetadata, Sitemap, Robots und Route Handlers. Dann die Types auf Promise<…> drehen, damit der nächste synchrone Read ein Compile-Error ist, kein Freitags-Overlay.

Die Checkliste, die wir abarbeiten, wenn das Overlay "cookies" sagt

Vier Checks, in dieser Reihenfolge, bevor irgendwer das einen Auth.js-Ausfall nennen darf.

Lies den exakten String. Route "…" used cookies(). \cookies` should be awaitedundparams should be awaitedsind der Next-15-Vertrag.cookies is not a function/.get is not a functionist meist ein Promise, das als Store behandelt wird, oder ein überschattetescookies-Binding. unauthorized/ Bounce auf/loginbei gültigem Session-Cookie ist oft der Layout- oder Middleware-Helper, kein widerrufener JWT. Wenn das Cookie da ist undiat` frisch, hör auf, Claims zu debuggen, und fang an, Awaits zu debuggen.

Am Boundary awaiten, das Promise typisieren. const store = await cookies(), const { id } = await params, const { q } = await searchParams, const h = await headers(). Den Prop-Typ auf Promise<{ … }> stellen. Eine synchrone ({ params: { id } })-Destrukturierung in der Signatur stehen lassen heisst, du hast nicht migriert; du hast id versteckt.

Folge den Helpern, die der Codemod nicht sieht. lib/session.ts, currentUser(), getToken(), alles was auth() oder cookies() wrappt. Diese Funktionen async machen, innen awaiten, an jedem Caller awaiten. Ein sync-Layout, das "nur den Chip braucht", ist der Vorfall. Soft-Navigation in eine Page, deren Layout params noch sync liest, ist, wie nur manche Routes werfen.

Metadata und die XML-Routes nicht überspringen. generateMetadata, sitemap.ts, robots.ts und route.ts teilen dieselben APIs und tauchen auf der Page, die du geklickt hast, nicht auf. Ein 500 auf /sitemap.xml nach einem 15-Bump ist dieser Bug, bis das Gegenteil bewiesen ist.

Drei Regeln, die das nächste Runtime-Flag überleben

Drei Regeln überleben diese Aufzeichnung und verallgemeinern sich über das hinaus, wie Next eine Dynamic API im nächsten Major nennt.

Eine Dynamic API ist ein Promise, auch wenn die Types vom letzten Jahr das Gegenteil sagen. cookies(), headers(), draftMode(), params und searchParams unwrapen auf dem Server mit await und auf dem Client mit React.use. Sync-Zugriff ist nicht "in Ordnung, bis du Streaming brauchst." Es ist eine Warning, die zum Throw wird. Der Fallback ist kein Feature, das du behalten solltest.

Der Fehler trägt den Namen des Helpers, nicht der API. auth(), das cookies should be awaited wirft, ist keine Auth.js-Regression. Ein Layout, das params should be awaited wirft, ist kein Routing-Bug. Ein Sitemap-500 ist nicht "SEO ist kaputt." Grep den Wrapper. Await den Store. Dann schau auf Session-Invalidierung, wenn das Cookie wirklich weg ist.

Codemode die Call-Sites, die du siehst; auditiere die, die du nicht siehst. Der offizielle Codemod ist der richtige erste Schritt. Die @next-codemod-error-Kommentare, die er in sync-Helpern hinterlässt, sind die eigentliche Arbeit. Sie löschen, ohne die Funktion async zu machen, heisst, das Overlay für den ersten authentifizierten Layout-Paint zu terminieren.

Die Komposition ist die Notiz. Wir haben einen Next-15-Bump als Auth.js-Ausfall behandelt, weil der Stack-Frame in auth() sass und der Chip im Root-Layout leer blieb. Produktion war ein Promise von cookies(), gelesen als Store, und ein params-Objekt, das kein Objekt mehr war. Ein await an der Layout-Boundary, Promise-Types auf den Props und ein Grep, der Helper einschliesst, hätten das Loch in der ersten Minute gezeigt — derselben Minute, die wir mit dem Rotieren von Secrets verbracht haben.

Wenn du mitten im Upgrade einer App-Router-App bist und das erste authentifizierte Layout cookies should be awaited wirft — buche einen kostenlosen AI-Potenzial-Check. Die Auth.js-JWT-Aufzeichnung ist die Session-Claim-Hälfte derselben "das Kostüm ist nicht die Schicht"-Lehre; die revalidateTag-Notiz ist die Erinnerung, dass headers() in einem Render-Log aus demselben Request kommen muss — und auf 15 heisst das, ihn zuerst zu awaiten.

acurio · Halluzinierte Zitate? Nicht in deinem Manuskript.

Citation‑Checker für Zotero. Findet halluzinierte oder nur teilweise gestützte Quellen in KI‑geschriebenen Texten. Thesis‑Pakete ab CHF 19, Schweizer Datenverarbeitung.

NÄCHSTER SCHRITTHat dich das interessiert?