nextjs · tutorial · agents

14 to 16 Upgrade: Caching Broke Everything

Next 14 to 16 changes fetch caching, params, PPR, and Turbopack at once. The corridor checklist in dependency order. Migrate calmly inside.

A horse-drawn wagon and onlookers in front of the ruined buildings after the San Francisco earthquake, 1906
A horse-drawn wagon and onlookers in front of the ruined buildings after the San Francisco earthquake, 1906 · Photo: H. D. Chadwick, public domain, via Wikimedia Commons

The upgrade that breaks in four places at once

npm install next@16, npm run build, and the terminal fills with four error species: params-are-Promises type errors, fetch calls returning stale-or-fresh wrongness, PPR imports that no longer exist, and Turbopack choking on a webpack plugin from 2023.

Each fix is ten minutes. Together they interleave: fixing params reveals cache breakage, fixing cache reveals PPR removal, fixing PPR reveals the bundler gap. Sequential surprises across an afternoon, each invalidating assumptions the last fix made.

Nextjs 14 to 16 upgrade pain is coordination, not difficulty — four small migrations sharing one build. This post orders them so each green build holds.

The corridor's four breaks compared

Break Fails at Fix time Blast radius
Async params Build (loud) ~10 min Every dynamic route
Fetch cache defaults Runtime (silent!) ~30 min + audit Every data fetch
PPR → cacheComponents Build (import error) ~20 min Partial-render routes
Turbopack default Build (plugin gap) ~15 min or opt-out Custom webpack configs

Three details make corridors worse than the sum of breaks:

  1. Silent breaks hide behind loud ones. Params scream at build while fetch-cache silently serves wrong data — teams fix the noise, ship, and discover staleness from users days later. Loud-first fixing order feels productive while the quiet break matures into an incident.
  2. Fixes interact across layers. Awaiting params changes layouts to async, which changes streaming boundaries, which changes what cacheComponents sees. Layered fixes need re-verification after each step — build-green once is meaningless until all four land.
  3. Skipped majors compound. 14→15→16 incremental is four small corridors; 14→16 direct is one big corridor with interacting breaks. Version debt compounds exactly like tech debt: skipped upgrades don't vanish, they accumulate into scarier leaps. Current-minus-a-beat beats leapfrogging.

The async-params post owned the loudest corner. This one runs the corridor.

Nextjs 14 to 16 upgrade corridor order

Dependency order: params (everything touches), cache (behavior), PPR (imports), bundler (tooling). Green builds between each.

Step 1 — Params first (the foundation everything reads).

npx @next/codemod@latest next-async-request-api .
grep -rn "params\.\|searchParams\." app/ --include="*.js" | grep -v "await"
# fix stragglers, update types to Promise<...>, wrap test mocks, BUILD GREEN

Params gate everything downstream: layouts, metadata, handlers all read them, so unfixed params poison every later verification. Codemod plus grep-zero plus mock repair (the async-params post in full), then a green build before touching anything else. Foundation first because everything stands on it.

Step 2 — Cache audit second (the silent behavior change).

# 15+ defaults: fetch() no longer caches unless forced — audit EVERY fetch:
grep -rn "fetch(" app/ lib/ --include="*.js"
# decide per call: { cache: "force-cache" } (static) or { cache: "no-store" } (live)
# revalidate intervals reviewed against staleness tolerance per route

Each fetch gets an explicit caching decision — the era of ambient caching is over, and undecided fetches are bugs wearing defaults. Stale-sensitive routes (prices, inventory, auth-adjacent) go no-store; static content goes force-cache with revalidation. Runtime-verify with network-tab sampling, not just builds: cache breaks are silent, so proof must be behavioral.

Step 3 — PPR then bundler (imports, then tooling).

# PPR → cacheComponents: rename imports, move partial-render boundaries per codemod
# Turbopack: try default build; webpack-only plugins → find Turbopack equiv or opt out:
experimental: { turbo: false }   # legitimate escape hatch, not defeat

PPR removal fails loudly at import time (easy), Turbopack gaps fail at plugin load (mechanical). The turbo opt-out is honorable when a critical plugin lacks support — pinned webpack while tracking the plugin's roadmap beats a broken build on principle. Tooling bends to shipping schedules, never the reverse.

That's the operating model — corridors as checklists, never mazes.

The corridor scorecard

Gate After step 1 After step 2 After step 3
Build Green (params) Green (cache explicit) Green (all four)
Dynamic routes clicked Working Working Working
Fetch behavior sampled — Correct per decision Correct
PPR routes render — — Migrated, working
Total time ~15 min ~45 min ~75 min

The price of it, named: 75 focused minutes plus click-throughs, against "just run the codemods and ship" (which leaves silent cache bugs for users to find).

BoringPush prices corridor discipline against incident archaeology: an afternoon of ordered migration beats a week of "why is pricing stale" tickets. Boring upgrades are the cheapest kind — pay in checklist time, not incident time.

FAQ

Should nextjs 14 to 16 upgrade go through 15 first?

Cleaner for forensics (which major broke what), slower overall (two corridors, two verifications). Big apps: step through 15 (blame isolation matters at scale). Small apps: direct to 16 with the four-gate checklist (faster, and breaks are few enough to attribute). Size decides the route; the checklist works either way.

How do I keep agents from making corridor upgrades worse?

Constrain the mission: one break per branch, grep-zero plus build-green before next break, no "while I'm here" refactors. Agents excel at mechanical corridor work inside fences and spiral without them (fixing params by redesigning routing). Corridor missions are ideal agent work precisely because verification is mechanical: build color plus grep counts.

What if a dependency hasn't published 16 support?

Pin the blocker, upgrade everything else, track the dependency's roadmap weekly. blockers fall into two bins: abandoned (replace — the upgrade exposed rot worth removing) and lagging (wait — majors trail by weeks normally). Never hold the framework hostage to one package; isolate, pin, proceed. The corridor moves; stragglers get replaced or catch up.

Do I need to migrate PPR usage if I never enabled it?

No — experimental-off PPR means nothing to migrate (skip step 3a, keep reading). But audit for accidental adoption (a flag flipped in a tutorial-following afternoon, partial boundaries half-built). Unused experiments cost nothing to leave; half-adopted ones break loudly. Know which you have before deciding there's nothing to do.

How do Turbopack and Webpack coexist during transition?

They don't in one build — but the opt-out flag lets the app ship on webpack while Turbopack compatibility is proven branch-side. Dual-track briefly (prod stable, branch experimental), converge when the plugin gap closes. Transitions are bridges, not jumps: traffic flows on the old span while the new one is load-tested.

When is NOT upgrading the right call?

Revenue weeks (launches, holidays, fundraising demos) and deep-dependency lag (native modules, auth adapters trailing). "Current minus a beat" already encodes this: trail by one minor, never by one major-plus. Not-upgrading is scheduling, not strategy — calendar the corridor for the next quiet week, and keep the quiet week sacred.

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