# 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.

Source: https://boringpush.com/blog/nextjs-16-async-params-breaking

## 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.**

```bash
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.**

```js
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.**

```bash
# 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

- [Router Cache Confusing You? Next.js Caching, Fixed](/blog/struggling-with-router-cache-fix) — the caching sibling in the same corridor
- [Thin Routes: Why Agents Fear Your 250-Line Handler](/blog/ai-agents-thin-routes-40-lines) — the shape absorbing the extra await
- [Next.js App Router Slow First Click? Fix the 1-5s Delay](/blog/nextjs-app-router-slow-first-click-fix) — the performance sibling worth auditing too
