nextjs · tutorial · agents

Next 16 Async Params Broke My Build: Fix

Next 16 made route params async — every params.id access now needs await. The codemod plus the 10-minute migration checklist. Fix inside.

The dog Hachiko lying patiently beside a station attendant and a woman, Shibuya Station, Tokyo, about 1933
The dog Hachiko lying patiently beside a station attendant and a woman, Shibuya Station, Tokyo, about 1933 · Photo: Unknown photographer, public domain, via Wikimedia Commons

Upgrade, build, wall of red

next@16 installed. npm run build. Every dynamic route fails: params.id is a Promise, .id is undefined, pages that rendered yesterday crash today. The error hints at async without naming all forty call sites.

Next 16's async-params change (params and searchParams became asynchronous — verify current codemod coverage before upgrading) is the flagship break in the 14→16 corridor: silent until build, total when hit, mechanical to fix once understood.

A broken Next 16 build is a rite of passage now. This post shortens it to ten minutes.

Sync assumptions vs async reality

Code shape Next ≤15 Next 16
params.id direct access Works Promise — undefined
searchParams.q direct Works Promise — undefined
{ params }: { params: { id: string } } types Correct Type error (now Promise)
Layouts reading params Sync fine Must await (async layout)
Tests mocking params Plain objects Must mock Promises

Three details make it bite harder than a normal breaking change:

  1. It fails at build, not at edit. TypeScript flags some sites, but dynamic accesses and JS files sail through to next build — the wall of red arrives minutes into CI, not seconds into editing. Late failure means big-bang discovery instead of incremental fixing.
  2. Layouts must go async too. Any layout touching params becomes an async component — which changes its streaming and suspense behavior subtly. The fix propagates upward beyond the flagged lines; grep finds usages, judgment finds the async boundaries.
  3. Tests lie by construction. Mocks passing plain-object params now mismatch runtime Promises — suites stay green while prod crashes. Every mock needs the Promise wrapper, or the suite certifies fiction. Update mocks in the same commit or don't bother.

The 14-to-16 upgrade post covers the corridor; this one owns its sharpest corner.

Nextjs 16 async params migration

Ten minutes: Codemod first, manual sweep second, mock repair third.

Step 1 — Run the codemod, then distrust it.

npx @next/codemod@latest next-async-request-api .
# then verify what it missed:
grep -rn "params\.\|searchParams\." app/ --include="*.js" --include="*.jsx" | grep -v "await"

The codemod handles canonical patterns (typed page props, standard handlers) and misses customs (destructured-then-passed params, JS files, wrapper functions). The grep is the real migration: every direct access without await is a crash, listed in seconds. Codemod proposes; grep disposes.

Step 2 — Await at the boundary, type as Promise.

export default async function Post({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;   // await ONCE, at the top, pass values down
  const post = await getPost(slug);
  // children receive slug (string), never params (Promise) — boundary rule
}

Await once at the route boundary, pass plain values down — children never see Promises, layouts await before rendering slots. The boundary rule keeps async contagion contained: one await per route, zero in components. Types updated alongside (Promise<{...}>), so the compiler guards the next author.

Step 3 — Repair mocks and prove with a build.

# tests: wrap every mocked params/searchParams in Promise.resolve({...})
# then the only proof that matters:
npm run build   # wall of red → silence. Ship it.

Build-green is the acceptance test (it failed there, it passes there). Plus one runtime click through every dynamic route — build success proves compilation, not behavior. The drill: codemod, grep-zero, mock-wrap, build-green, click-through. Ten minutes, documented, repeatable per app.

So that's the playbook — upgrades as checklists, not adventures.

Nextjs 16 async params migration scorecard

Check Before fix After fix
npm run build Wall of red (40+ errors) Green, same routes
Direct accesses remaining Dozens grep returns zero
Mock fidelity Plain objects (lying) Promises (truthful)
Runtime dynamic routes Crashing Clicked through, working
Migration time — ~10 min + click-through

Cost, admitted: async params add one await per route forever — trivial noise with real purpose (enabling streaming and partial-rendering patterns underneath).

BoringPush accepts the tax because the alternative (pinned old versions) accrues upgrade debt at compound interest. Migrate promptly, mechanically, and the corridor stays a hallway — delay, and it becomes the maze the version-hell posts describe.

FAQ

Are nextjs 16 async params the only breaking change in the upgrade?

No — the corridor includes fetch-cache defaults, PPR-to-cacheComponents, Turbopack default, and middleware-to-proxy moves (each with its own post here). Async params just fails loudest and first. Budget the full corridor checklist, not the single fix: migrate params today, audit the siblings this week. Corridors reward checklists, punish spot-fixes.

Can I stay on sync params with a compatibility flag?

Temporary flags exist per release (verify current — flags expire), but pinning old behavior mortgages the future: security patches eventually require the new major, and skipped flag windows compress into forced migrations later. Migrate on your schedule (this quarter) rather than the flag's (its removal). Ten minutes now beats a forced weekend later.

How do I find every params usage in a large app?

The grep above plus TypeScript (tsc --noEmit flags typed sites) plus the build itself as final census — three nets, decreasing mesh. Large apps add the codemod's report as net zero. No single net catches customs, wrappers, and JS files together; run all three and treat grep-zero plus build-green as done. Census completeness is the whole game.

Do route handlers (API) need the same await treatment?

Yes — GET(request, { params }) receives Promised params identically; await before use, same boundary rule. API routes actually break harder (no partial rendering to mask undefined — straight 500s). The thin-routes post's four-movement shape absorbs the await naturally: validate (after await), auth, call, respond. One extra line, zero structural change.

What about generateStaticParams / generateMetadata with params?

Both receive the same async params — await inside before use. generateMetadata awaiting params is the subtle one (metadata functions rarely touched since creation, easily missed in sweeps). Include metadata and static-param functions explicitly in the grep patterns. Forgotten corners break quietly at build-or-request time; sweep them deliberately.

Should I upgrade to 16 at all, or wait for 16.x stability?

Upgrade on purpose, not on release day: wait for x.1+ plus your dependency tree's compatibility (native modules, auth adapters, UI kits trail majors by weeks). Then migrate in a quiet week with the corridor checklist green. Bleeding edge is for hobby apps; measured currency is for revenue apps. Current-minus-a-beat beats either extreme.

Keep reading

+ all the boring stuff (analytics, auth, SQLite ↔ Supabase, full marketplace, webhooks)

The Agent-First Shipping Kit

Command from your phone.

Built so autonomous AI agents build what you want without breaking what's already paying you.

$199

Get BoringPush

One-time payment · Lifetime updates · Unlimited projects.

Keep reading