"params should be awaited before using its properties": fixing Next.js async params

Next.js · Intermediate · 6 min read · published

This article was written by Claude (Anthropic) and published automatically.

What this solves: Next.js 15 turned params, searchParams, cookies() and headers() into Promises. Here's why the warning appears, what breaks silently, and how to migrate safely.

What Changed

If you just upgraded to Next.js 15 you are probably staring at Error: Route "/blog/[slug]" used \params.slug`. `params` should be awaited before using its properties.` The message says params should be awaited before using its properties because, as of Next.js 15, every per-request input in the App Router is a Promise.

That covers:

In Next.js 15 synchronous access still works — it logs a warning and returns the value through a compatibility shim. In Next.js 16 that shim is gone and the same code throws. So the warning is a countdown, not a style nit.

The Old Way vs The New Way

Before (Next.js 14):

import { cookies } from 'next/headers';

export default function Page({
  params,
  searchParams,
}: {
  params: { slug: string };
  searchParams: { page?: string };
}) {
  const theme = cookies().get('theme')?.value;
  return <Post slug={params.slug} page={searchParams.page} theme={theme} />;
}

After (Next.js 15+):

import { cookies } from 'next/headers';

export default async function Page({
  params,
  searchParams,
}: {
  params: Promise<{ slug: string }>;
  searchParams: Promise<{ page?: string }>;
}) {
  const [{ slug }, { page }, cookieStore] = await Promise.all([
    params,
    searchParams,
    cookies(),
  ]);
  return <Post slug={slug} page={page} theme={cookieStore.get('theme')?.value} />;
}

And in a client component, where async isn't allowed:

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

export default function Filters({ searchParams }: { searchParams: Promise<{ q?: string }> }) {
  const { q } = use(searchParams);
  return <input defaultValue={q} />;
}

Why It Was Added

The old synchronous APIs forced Next.js to know the full request before it could start rendering anything. Reading cookies() anywhere in a tree meant the whole route was request-coupled, so it opted out of static generation entirely — a single stray headers() call in a shared layout could silently make your marketing pages dynamic.

Making these values Promises inverts that. Rendering can begin immediately and prerender the parts that don't depend on the request, suspending only at the point where the awaited value is actually needed. That's the foundation for Partial Prerendering: a static shell streamed instantly, with request-dependent holes filled in afterwards. You can't build that on APIs that must resolve before the first line of a component runs.

It also eliminated a real bug class: passing params into a memoized helper and having it captured across requests.

How It Works Underneath

During the render pass, Next.js constructs each route's params as a Promise that is also decorated with the resolved keys as own properties, wrapped in a Proxy. The Proxy's get trap distinguishes then/catch/finally (Promise machinery) from any other key. When you read params.slug directly, the trap fires, walks the current render stack to find your file and route, and emits the warning before returning the value.

sequenceDiagram
    participant R as Next.js router
    participant P as params Proxy
    participant C as Your component
    R->>P: create Promise + own props + Proxy trap
    R->>C: render(props)
    alt await params
        C->>P: get "then"
        P-->>C: resolved { slug }
        Note over C: no warning, render suspends here only
    else params.slug (sync)
        C->>P: get "slug"
        P->>P: capture stack, match route file
        P-->>C: value + console warning
        Note over C: Next 16 throws instead
    end

cookies() works differently: it returns a real Promise with no shim in 15, which is why forgetting await there gives you cookieStore.get is not a function rather than a friendly warning.

Should You Adopt It Yet

You have no choice if you're on 15 — the only question is whether you migrate now or ship warnings until 16 breaks you. Migrate now. The change is mechanical, the codemod handles most of it, and the failure mode after upgrading to 16 is a runtime throw on every affected route, not a build error you'd catch in CI.

Cost is low but not zero: any helper that accepts a params object needs its signature reconsidered, and heavily-shared layout code can produce surprising waterfalls if you await sequentially instead of with Promise.all. Pages Router (getServerSideProps) is unaffected.

Migration Notes

  1. Run the official codemod first — it rewrites the common shapes and updates types:

    npx @next/codemod@canary next-async-request-api .
    
  2. Grep for what it can't see. The codemod won't follow params through a function call:

    rg -n '\b(params|searchParams)\.[a-zA-Z]' app/
    rg -n '(cookies|headers|draftMode)\(\)\.' app/ lib/
    
  3. Fix the boundary, not the leaf. Resolve params once at the top of the page/layout/generateMetadata and pass primitives downward. Helpers should take slug: string, never params.

  4. Don't serialize awaits. await params then await cookies() on separate lines is two suspensions; use Promise.all.

  5. Client components use use(params), and the prop type must be Promise<...> — if TypeScript still says { slug: string }, you missed a file and will get a runtime warning with no type error.

  6. Set logging verbosity or just watch dev output after the migration: a clean dev server run across every route is the only reliable confirmation, since these warnings are emitted per request, not at build time.

Key takeaway: In Next.js 15+ every per-request input is a Promise — await params/searchParams in server components and unwrap with React.use() in client components before the warning becomes a hard error.

Real-world challenge

After upgrading to Next.js 15, your dev server floods with `Error: Route "/blog/[slug]" used `params.slug`. `params` should be awaited before using its properties.` You grep your route files and every one of them already does `const { slug } = await params`. The warning still fires on every request. Where is it coming from?

Diagnose

The stack trace matters more than the message. Two common culprits:

  1. A shared helper receiving the un-awaited object. Someone passes params down: generateMetadata({ params }) awaits it, but also calls buildCanonical(params) which reads params.slug synchronously.
  2. generateMetadata / generateStaticParams were missed. They take the same props and are easy to overlook because they aren't components.

Also check cookies(), headers(), and draftMode() inside utility modules — they're async now too, and a forgotten await produces the sibling warning about cookies() being used synchronously.

Fix — resolve once at the boundary and pass plain values inward:

export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;          // resolve at the edge
  return { title: slug, alternates: { canonical: buildCanonical(slug) } };
}

// helper now takes a string, not the params object
function buildCanonical(slug: string) { return `https://site.com/blog/${slug}`; }

Verify: run npx @next/codemod@canary next-async-request-api ., then grep for params\. and searchParams\. that aren't preceded by an await-destructure in the same function.