Problem
Migrated a client blog from Next.js 15.4 to 16.2 over the weekend. next build compiled. next dev served the homepage. The moment I opened /blog/some-slug, the page crashed:
TypeError: Cannot destructure property 'slug' of 'params' as it is undefined.
at BlogPage (app/blog/[slug]/page.tsx:12:9)
On another route I got the more explicit version:
Error: Route "/products/[id]" used `params.id`. `params` should be awaited
before using its properties.
Nothing in the route file had changed. The build logs were clean. If your dynamic routes started throwing params should be awaited or params is undefined after upgrading to Next.js 15.5+ or 16.x, this is the migration everyone is missing.
Why It Happens
Starting with Next.js 15, the App Router changed params and searchParams from synchronous objects to Promises. The reasoning is covered briefly in the Next.js upgrade guide — async params lets the framework stream partial prerenders without blocking on the param resolution step.
In 15.x, the old synchronous access still worked with a console warning. In 16.x, the warning became a hard error. The old pattern:
// app/blog/[slug]/page.tsx (WORKS IN 14, WARNS IN 15, BREAKS IN 16)
export default function BlogPage({ params }: { params: { slug: string } }) {
const { slug } = params;
return <article>Slug: {slug}</article>;
}
blows up in 16 because params is now Promise<{ slug: string }>. Destructuring a Promise gives you undefined for every key. The runtime then either throws Cannot destructure immediately, or worse, silently renders with empty data and you only notice in production.
The same change hits four places:
page.tsx—paramsandsearchParamslayout.tsx—paramsroute.tsAPI handlers — the second argument{ params }generateMetadata()— its first argument includesparams
If your codebase has twenty dynamic routes, every single one needs updating. The codemod ships with Next.js, but it misses custom generic types and JSDoc signatures.
The Fix
Two steps. Run the codemod for coverage, then clean up what it misses by hand.
1. Run the official codemod.
Next.js ships a dedicated codemod for exactly this migration:
npx @next/codemod@latest next-async-request-api .
It rewrites page.tsx, layout.tsx, and route.ts files to await params and searchParams. Review the diff — I always commit it on its own branch so the framework rewrite is separate from any behavior changes I make manually.
After the codemod, a typical page looks like this:
// app/blog/[slug]/page.tsx
type PageProps = {
params: Promise<{ slug: string }>;
searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
};
export default async function BlogPage({ params, searchParams }: PageProps) {
const { slug } = await params;
const { preview } = await searchParams;
const post = await getPost(slug, { preview: preview === "1" });
return <article>{post.title}</article>;
}
export async function generateMetadata({ params }: PageProps) {
const { slug } = await params;
const post = await getPost(slug);
return { title: post.title };
}
The page function is async, the props types are Promise<...>, and every access to the params object is awaited. This compiles cleanly in both 15.x and 16.x so you can ship it before the upgrade, not after.
2. Fix custom wrappers and generics the codemod missed.
If you have shared types or higher-order components that forward params, the codemod usually skips them. Search your repo for params: and searchParams: to find them:
grep -rn "params: {" app/ --include="*.tsx" --include="*.ts"
Common patterns that break silently:
// BEFORE — a reusable page wrapper that typed params as an object
export function withAuth<T extends { params: { id: string } }>(
Page: React.ComponentType<T>
) {
return async function Wrapped(props: T) {
const session = await getSession();
if (!session) redirect("/login");
return <Page {...props} />;
};
}
// AFTER — params is a Promise, keep the wrapper generic
export function withAuth<T extends { params: Promise<{ id: string }> }>(
Page: React.ComponentType<T>
) {
return async function Wrapped(props: T) {
const session = await getSession();
if (!session) redirect("/login");
return <Page {...props} />;
};
}
Middleware and route handlers also need updating:
// app/api/posts/[id]/route.ts
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const post = await db.post.findUnique({ where: { id } });
return Response.json(post);
}
Forgetting await in a route handler is especially nasty because you will get id: undefined passed to your database query, and Prisma or Drizzle will return the first row in the table. You will not notice until a tester hits a valid-looking URL and sees the wrong post.
3. Add a type guard so silent failures get loud.
I wrap my common param-reading logic to throw if the key is missing:
// lib/route-params.ts
export async function readParam<T extends Record<string, string>>(
params: Promise<T>,
key: keyof T
): Promise<string> {
const resolved = await params;
const value = resolved[key];
if (typeof value !== "string" || value.length === 0) {
throw new Error(`Missing required route param: ${String(key)}`);
}
return value;
}
Then in routes:
const slug = await readParam(params, "slug");
If anything ever passes an empty object (stale client bundle, wrong codemod, typo), the error surfaces at the param read site instead of three layers down in a database call.
Gotchas
searchParams is a Promise too. The codemod handles it, but any custom helper that previously treated it as plain is broken. Re-check search, filter, and pagination components.
Client Components still receive synchronous params. The Promise shape only applies to Server Components and Route Handlers. If you pass params down to a Client Component, you must await it on the server first and forward the resolved object.
Turbopack builds can mask the error locally. Turbopack's fast HMR sometimes reuses a cached module after you edit the page file. If the error appears to go away without any code change, restart the dev server — the old compiled output was still running. This is the kind of thing that also hits Server Actions, which I covered in my Next.js 16 Server Actions invalid error fix.
Need a Next.js 16 Migration Done Without Breaking Prod?
I plan and ship App Router upgrades on live codebases with hundreds of dynamic routes, handling async params, cache migrations, and Server Action keys in one clean PR. If your team is stuck mid-upgrade, book a session on my services page and I'll get your build green.