Skip to content
Malik Hamza Shabbir
Web DevelopmentNext.jsReactMigrationTurbopack

Next.js 16 Migration Guide: Fixing the Silent Breakages

HSMalik Hamza Shabbir8 min read

In short

Four changes in Next.js 16 can break a production app without throwing a single error: the middleware.ts to proxy.ts rename, the deprecated single-argument revalidateTag, caching flipping to opt-in, and async params. I migrated my production reputation SaaS to 16.2 in about 14.5 hours spread over three days, and every problem I hit was a correctness bug, not a crash. This post is the checklist the official upgrade guide (last updated May 13, 2026) does not give you: the symptom, the root cause, the exact fix, and how you would actually notice each one in production.

Next.js 16 Migration Guide: Fixing the Silent Breakages - branded cover card by Hamza Shabbir
On this page

What breaks silently when you upgrade to Next.js 16?

Four changes ship with no build error and no runtime exception: middleware.ts is ignored until you rename it to proxy.ts, single-argument revalidateTag serves stale data, caching is now opt-in so formerly static routes go dynamic, and params became a Promise so property access returns undefined in plain JavaScript. Everything compiles. Everything deploys. The bugs show up as wrong behavior, not red text.

Version context first, because it matters for what you are upgrading into: Next.js 16 went GA on October 21, 2025, 16.2 shipped on March 18, 2026 with roughly 400% faster dev startup, and 16.3 has been in canary since June 4, 2026.

Here is the table I wish I had before I started:






Why is middleware.ts silently ignored?

middleware.ts is silently ignored in Next.js 16; it must be renamed proxy.ts and runs on the Node.js runtime only, because the edge runtime for it was removed. There is no build warning loud enough to stop you. On my staging deploy, the symptom was a smoke test hitting /dashboard logged out and getting a 200 instead of a 307 redirect to /login. If I had shipped that to production, every protected route in my reputation SaaS would have been publicly reachable.

The fix is mechanical:

TS
// proxy.ts (was middleware.ts)
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

export default function proxy(request: NextRequest) {
  const session = request.cookies.get('session')
  if (!session && request.nextUrl.pathname.startsWith('/dashboard')) {
    return NextResponse.redirect(new URL('/login', request.url))
  }
  return NextResponse.next()
}

export const config = { matcher: '/dashboard/:path*' }

Also delete any export const runtime = 'edge' and replace edge-only APIs. The upside: you now have the full Node.js API surface in your proxy, so things like crypto and proper JWT libraries just work.

Why does revalidateTag serve stale data now?

The single-argument revalidateTag('reviews') form is deprecated in Next.js 16 and behaves as stale-while-revalidate: the next request after the mutation still gets the old cached entry, and only the request after that sees fresh data. There is no build error and no console warning at the call site, which is why people end up googling "revalidateTag not working Next.js 16" instead of reading a stack trace.

In my SaaS the symptom was specific: a business owner approves an AI-drafted reply, the Server Action succeeds, and the review list still shows the reply as pending until a second refresh. The fix is updateTag('reviews') inside Server Actions when you need read-your-own-writes, or revalidateTag('reviews', 'max') elsewhere for an immediate expire. This matters more than it used to, because pages are no longer only read by humans. I covered in what WebMCP means for web apps and AI agents how agents increasingly consume your pages directly, and an agent will happily act on stale data you forgot to expire.

Why did my static pages suddenly go dynamic?

Next.js 16 makes caching opt-in. Nothing is cached unless you say so, which means routes that were static or ISR in 15 quietly become fully dynamic and hit your data layer on every request. No error, just worse numbers. On my public review pages, p95 TTFB went from about 120ms to about 460ms and Postgres CPU roughly doubled until I added explicit caching back.

You notice this one in dashboards, not logs: rising TTFB, rising database connections, rising serverless invocation costs. If you do not have those graphs, set them up before you migrate.

What changed about params and searchParams?

params and searchParams are now Promises with no synchronous fallback. TypeScript catches this at compile time, but in plain JavaScript params.id on a Promise is simply undefined, so your query runs with an undefined ID and you get empty pages or 404s. The Next.js 15 warning string, params should be awaited before using its properties, is gone; the grace period ended. The fix is const { id } = await params, and the official codemod handles the overwhelming majority of these.

'use cache' vs ISR vs fetch caching: which one should you use?

Use 'use cache' for anything you control end to end, keep fetch-level caching for third-party APIs, and treat classic ISR exports as legacy that still works but is no longer the forward path. 'use cache' is a directive you place at the top of a file, component, or async function to tell Next.js to cache its output until one of its tags is revalidated or its cacheLife expires. This is the single most confused concept of the release, so here is the decision table I now use:







ChangeSymptomFix
middleware.ts renamed to proxy.tsAuth redirects, rewrites, and header rules silently stop runningRename the file to proxy.ts, export proxy, delete runtime: 'edge'
Single-arg revalidateTag(tag) deprecatedMutations save, but the next request still shows stale dataupdateTag(tag) in Server Actions, or revalidateTag(tag, 'max')
Caching flipped to opt-inFormerly static/ISR routes render on every request; TTFB and DB load climbAdd 'use cache' with cacheLife/cacheTag to routes that should cache
params/searchParams are asyncparams.id is undefined; queries return empty results or 404sawait params (the codemod rewrites most call sites)
Data freshness needTypical routeUse
Changes only on deployMarketing pages, docs'use cache' with default cacheLife
Refresh on a schedule (minutes to hours)Public listings, blog, review widgets'use cache' + cacheLife('hours') (the ISR replacement)
Refresh after user mutationsAuthed pages mutated by Server Actions'use cache' + cacheTag + updateTag
Caching someone else's API responseAny route calling third-party APIsfetch(url, { next: { revalidate: 300 } })
Always fresh, per-userAdmin panels, billing, anything with PIINo cache, stay dynamic

The mental shift: in 15 you fought the framework to make things dynamic; in 16 you declare what is cacheable and everything else is dynamic by default. My rule on the SaaS was simple. Public, shared, tolerant of minutes of staleness: cache it. Authenticated or money-related: do not.

Diagram of the four silent Next.js 16 breakages and the new opt-in caching model mapping use cache, ISR, and fetch caching to data freshness
Diagram of the four silent Next.js 16 breakages and the new opt-in caching model mapping use cache, ISR, and fetch caching to data freshness

What breaks when Turbopack becomes the default build?

Turbopack is the default for both dev and production builds in Next.js 16, and your webpack() config in next.config is ignored. The tell is this exact line in your build output: Webpack is configured while Turbopack is not, which may cause problems. Anything that depended on a custom loader breaks next, usually SVGR, with Module not found: Can't resolve errors or SVGs importing as URLs instead of components.

The fix is to port loaders to turbopack.rules:

TS
// next.config.ts
const nextConfig = {
  turbopack: {
    rules: {
      '*.svg': {
        loaders: ['@svgr/webpack'],
        as: '*.js',
      },
    },
  },
}

export default nextConfig

If a plugin has no Turbopack equivalent yet, next build --webpack is the escape hatch, but treat it as temporary. The payoff is real: on my machine, dev cold start dropped from about 7.5s to under 2s after 16.2, and the production build went from 2m40s to a bit over a minute. That alone justified the migration for me.

Which changes does the codemod handle, and which are manual?

Run npx @next/codemod@canary upgrade latest first; it reliably handles the syntactic changes and none of the semantic ones. On my codebase it caught every async params/searchParams rewrite in TypeScript files, the import renames, and the config key moves. That is the easy half.

What it cannot do for you:

  • Decide what should be cached. The opt-in caching audit is judgment work, route by route.

  • Fix revalidateTag semantics. It will not choose between updateTag and revalidateTag(tag, 'max') because that depends on your UX.

  • Validate your proxy.ts logic. The rename is trivial; confirming your matchers and auth still behave is not.

  • Port webpack loaders. turbopack.rules needs a human who knows why each loader existed.


A loud failure costs you an afternoon. A silent failure costs you a week, because you spend the first six days not knowing it exists.

How long does a Next.js 16 migration take on a production app?

About 14.5 hours for my reputation SaaS: roughly 38 routes, 11 Server Actions, two cron jobs, one developer. Here is the actual breakdown, so you can budget against your own route count:

  1. Read the upgrade guide, audit dependencies for Turbopack support: 1.5 h

  2. Run the codemod, review the diff: 1 h

  3. Fix async params leftovers in plain JS utility files the codemod missed: 2 h

  4. Rename middleware.ts to proxy.ts, remove edge runtime assumptions: 1 h

  5. Sweep every revalidateTag call, convert to updateTag where actions need fresh reads: 1.5 h

  6. Route-by-route cache audit, adding 'use cache', cacheLife, and cacheTag: 4 h

  7. Turbopack build fixes (SVGR, one Sass quirk): 1.5 h

  8. Staging deploy with route-by-route verification of cache behavior and auth: 2 h


Step 6 dominates, and it scales with route count, not codebase size. The verification pass in step 8 reuses the same discipline I described in my audit checklist for fixing vibe-coded apps : open every route, check headers, confirm the cache state you expect. A good chunk of my app rescue and optimization work this spring has been exactly this, untangling Next.js 16 upgrades where someone shipped the codemod output straight to production and only noticed when customers reported stale dashboards.

Should you wait for Next.js 16.3?

No. Migrate to 16.2 now; it shipped on March 18, 2026, has had almost three months of patch releases, and the dev startup improvement is worth the effort by itself. 16.3 has been in canary since June 4, 2026 and is incremental, not corrective. There is no breaking change on the horizon that would invalidate work you do today.

My verdict after doing it on a revenue-generating app: Next.js 16 is worth migrating to for the dev-server speed alone, but do it behind a staging deploy with route-by-route cache verification, because the failures are correctness bugs, not crashes. Nothing will page you. Your error tracker stays green while users see stale data and your middleware does nothing. Every greenfield build in my MVP development work starts on 16.2 now, and it is the default across my web development client projects, but I will not upgrade an existing production app without that verification pass.

Key takeaways

  • Four Next.js 16 changes break apps with zero errors: the proxy.ts rename, deprecated single-arg revalidateTag, opt-in caching, and async params.

  • middleware.ts is silently ignored: rename it to proxy.ts, and note it runs on the Node.js runtime only since edge support was removed.

  • Caching is now opt-in: routes that were static in 15 go dynamic in 16, so watch TTFB and database load, not error logs.

  • The codemod handles syntax, not semantics: async params get rewritten for you; cache decisions and revalidation behavior do not.

  • Budget by route count: my 38-route production SaaS took about 14.5 hours, and the route-by-route cache audit was the largest single step at 4 hours.

FAQ

Why is my middleware.ts not running in Next.js 16?

Because Next.js 16 renamed it. middleware.ts is silently ignored; the file must be called proxy.ts and export a `proxy` function, and it runs on the Node.js runtime only since edge support was removed. There is no error, so auth redirects and rewrites just stop. Rename the file and remove any `runtime: 'edge'` config.

Is revalidateTag deprecated?

The single-argument form is. In Next.js 16, `revalidateTag('tag')` behaves as stale-while-revalidate, so the next request still serves old data. Use `updateTag('tag')` inside Server Actions when the user must immediately see their own change, or `revalidateTag('tag', 'max')` for an immediate expire from webhooks and route handlers.

Can I still use webpack with Next.js 16?

Yes, as an escape hatch. Turbopack is the default for dev and production builds, and your `webpack()` config is ignored with a warning. Run `next build --webpack` to opt back in temporarily, but plan to port custom loaders to `turbopack.rules`, since the webpack path is clearly in maintenance mode.

Do I have to rewrite ISR routes to 'use cache'?

Not immediately. The classic `revalidate` export still works in Next.js 16, so existing ISR routes keep functioning. The forward path is `'use cache'` with `cacheLife`, which gives the same scheduled-refresh behavior with finer control. I migrated mine during the cache audit, but it is the one change you can safely defer.

Working on something like this?

I build web apps, AI features, and mobile products for clients. If this article matches a problem you have, tell me about it.

Start a conversation
HS

Malik Hamza Shabbir · Full-Stack & AI Engineer

I build full-stack and AI products solo: a reputation SaaS in production, RAG pipelines, and React Native apps. I write from what I ship, not from documentation summaries.

Related articles