← Blog

Next.js 16 async request APIs: what forgetting await on params, searchParams and headers() really does

Next.js 16 makes headers, cookies, params and searchParams async. Forget await and values go silently undefined; the generated type that catches it.

In Next.js 16, headers(), cookies(), params and searchParams are async, and forgetting to await them is supposed to be a compile error. It is, when the types are right. The kit's sign-in page had a hand-written type that was wrong in a different way: it compiled cleanly, and a repeated ?redirect= crashed the page with a 500 in production. I also measured what happens when the types are loose enough to let a missing await through. The page does not crash. It renders, with your route params quietly set to undefined.

What changed in Next.js 16

Next.js 15 made these request-time APIs async but kept a temporary synchronous fallback. The Next.js 16 upgrade guide ends that: "Starting with Next.js 16, synchronous access is fully removed. These APIs can only be accessed asynchronously." The list is cookies, headers and draftMode, params in layouts, pages, route handlers, default.js and the image files (opengraph-image, twitter-image, icon, apple-icon), and searchParams in pages.

In the kit, the dashboard is the page that uses most of them:

export default async function DashboardPage({
  searchParams,
}: {
  searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
}) {
  // The real, DB-backed session check (proxy.ts only did the cookie check).
  const session = await auth.api.getSession({ headers: await headers() });
  if (!session) redirect("/sign-in?redirect=/dashboard");

  const justUpgraded = (await searchParams).upgraded === "1";
  // ...
}

headers() is awaited before it is handed to Better Auth, and searchParams is typed as a promise of exactly what the platform delivers. Every value is a string, an array of strings or missing.

What forgetting await actually does

To see the failure mode without the compiler stepping in, I added a throwaway route to a production build, typed its props as any, and read everything synchronously:

export default async function Page(props: any) {
  const slug = props.params.slug;      // no await
  const q = props.searchParams.q;      // no await
  const ua = (headers() as any).get("user-agent"); // no await
  // ...
}

Requesting /probe-sync/hello?q=1 returned 200, with slug and q both undefined, because params and searchParams are promises and a promise has no slug property. The headers() call threw TypeError: (0 , c.headers)(...).get is not a function, which uncaught would be a 500. The server log printed no warning about either. So the outcome depends entirely on your types. Correct types fail the build. Loose types give you a page that renders, with whatever the missing value was supposed to drive silently gone.

The generated type that would have caught our crash

The sign-in page typed its own props:

searchParams: Promise<{ redirect?: string }>;

That compiles, and it is not what Next.js delivers. The page docs show that ?a=1&a=2 arrives as { a: ['1', '2'] }. A request with redirect repeated handed the form an array, the form called startsWith on it, and the page returned a 500. That bug is fixed now, alongside an open redirect in the same lines, as covered in preventing an open redirect after login in Next.js.

Next.js generates the honest type for you. npx next typegen (also run by next dev and next build) writes .next/types/routes.d.ts, which declares a global PageProps:

interface PageProps<AppRoute extends AppRoutes> {
  params: Promise<ParamMap[AppRoute]>
  searchParams: Promise<Record<string, string | string[] | undefined>>
}

To check what it would have done, I rewrote the old sign-in page with it, props: PageProps<"/sign-in">, and ran tsc. It refused: error TS2322: Type 'string | string[]' is not assignable to type 'string'. The production crash would never have compiled. params gets the same treatment. PageProps<"/blog/[slug]"> knows slug is a string, because the generated ParamMap is built from the actual route folders. The docs note it "is a globally available helper" that "doesn't need to be imported".

The kit's fix types the param by hand as string | string[], which is correct today. PageProps is the version that cannot drift away from what Next.js actually sends.

In client components, use() instead of await

Client components cannot be async, so they cannot await. The page docs' answer is React's use:

"use client";
import { use } from "react";

export default function Page({ searchParams }: PageProps<"/sign-in">) {
  const { redirect } = use(searchParams);
  // ...
}

Things that bite

  • searchParams is not URLSearchParams. The docs are explicit: it "is a plain JavaScript object". There is no .get(), and repeated keys become arrays.
  • Reading any of these makes the route dynamic. The docs call them request-time APIs whose values "cannot be known ahead of time". Reading searchParams in a page you expected to be static moves it to render on every request.
  • Image metadata is half and half. In 16 the opengraph-image and icon functions receive params and id as promises, but generateImageMetadata "continues to receive synchronous params". The share-card setup is in Open Graph images in the Next.js App Router.
  • cookies() is read-only in pages. Setting or deleting a cookie only works in a Server Function or Route Handler, because "HTTP does not allow setting cookies after streaming starts".
  • There is a codemod. npx @next/codemod@latest next-async-request-api . adds the missing awaits for cookies(), headers() and draftMode(). Where it cannot be sure, it adds a typecast or a comment for you to review.

Shipwright, the Next.js and Claude starter kit this blog documents, is built on Next.js 16 with these APIs awaited throughout, including the sign-in fix above.