Guide

Next.js App Router migration without breaking production

The PainHunt Team · September 10, 2026 · 5 min read

TL;DR: Migrating a small blog to the Next.js App Router is easy, and that is why most tutorials use one. Migrating an interactive app with real users fails in four specific ways: use client cascading through the import graph, opaque caching serving stale authenticated data, third-party libraries that assume browser globals, and the temptation to do it in one giant pull request. Each has a concrete mitigation.

Why the tutorials do not transfer

PainHunt aggregates developer complaints across platforms. Searching that dataset for records explicitly mentioning the App Router, use client, Server Components, or the Pages Router returns 391 scored entries spread across twelve platforms — most heavily GitHub Discussions (137), Dev.to (79), Mastodon (44), Medium (36), and GitHub (35). The median intensity of the individual pain points in those records is 7.0 out of 10.

Two things stand out in that distribution. The first is breadth: this is not one angry forum thread, it is the same complaints recurring in a dozen independent places. The second is where the volume sits — GitHub Discussions is the single largest source, which means a large share of these reports come from people filing them against the libraries and frameworks themselves, not venting on social media.

The single most repeated observation across those sources is not a bug report. It is a mismatch: the published guides cover simple blog migrations, and the apps people actually have are messy and interactive with thousands of active users. The gap between those two produces the four failures below.

Failure 1 — The use client cascade

What happens: you add 'use client' to one component that needs a hook. That component is imported by a context provider, which is imported by a layout. Because the directive marks a boundary rather than a file, everything downstream of it joins the client bundle. Developers report discovering that most of their tree had silently become Client Components — losing the server-rendering benefit that motivated the migration.

Mitigation: push 'use client' as far down the tree as it will go. A component that needs interactivity should be a small leaf, wrapped by server components, not a provider near the root. Watch bundle size per route as a regression signal — if it grows after a migration step, a boundary moved up when you did not intend it to. Barrel files (index.ts re-exports) are a common accelerant here, because they pull unrelated modules into the same graph.

Failure 2 — Caching that only misbehaves in production

What happens: the App Router applies caching at several layers with distinct invalidation rules. The reported production symptom is stale authenticated data — a user seeing content that belongs to a previous request — and the reason it is so costly is that it usually does not reproduce locally, where caches behave differently.

Mitigation: treat caching as an explicit design decision per route rather than something to discover. Before migrating any authenticated route, write down which layer is allowed to cache it and what invalidates it. Add an end-to-end test that logs in as two different users in sequence and asserts that the second never sees the first's data. That single test catches the entire class of failure and is the highest-value thing you can write before starting.

Failure 3 — Third-party libraries that assume a browser

What happens: libraries written before React Server Components existed reference window or document at module scope. Imported into a Server Component, they throw window is not defined. Drag-and-drop and analytics libraries come up repeatedly in the complaint data.

Mitigation: inventory this before you start, not during. Grep your dependencies for browser globals at module scope and list which ones will need a client wrapper. Most cases are solved by a thin 'use client' wrapper component or a dynamic import with server rendering disabled — but the count matters for estimating, and finding it out mid-migration is what turns a two-week estimate into a two-month one.

Failure 4 — The one giant pull request

What happens: because the migration touches routing, data fetching, and component boundaries at once, it feels indivisible. Teams describe fear of a single enormous PR, and weeks of fighting the framework with no shippable intermediate state.

Mitigation: the routers coexist. Order the work deliberately:

  1. Leaf routes with no auth — marketing pages, docs. Low risk, proves the toolchain.
  2. Read-only authenticated routes — where the caching test from Failure 2 earns its keep.
  3. Interactive routes — forms and mutations, one at a time.
  4. Shared layout and providers — last, because this is where the use client cascade lives.

Ship each step. If a step cannot ship independently, it is too big.

A pre-migration checklist

  • Count dependencies that touch window/document at module scope
  • Write the two-user caching test before migrating any authenticated route
  • Record current per-route bundle sizes as a baseline
  • Identify every context provider — these are the cascade risks
  • Confirm every route can be migrated and shipped independently
  • Agree what "done" means per route, so partial migration is not treated as failure

The honest caveat

Not every app should migrate. If your Pages Router application is stable, your team is small, and you are not blocked on a feature that requires Server Components, "later" is a legitimate engineering decision. The complaint data contains a fair number of migrations that were undertaken because the App Router is newer rather than because anything was wrong — and those are the ones where people report the most regret about the time spent.

Frequently asked questions

Can you migrate to the App Router incrementally?

Yes. The Pages Router and App Router can coexist in one Next.js application, routed by directory. This is the only sane approach for an app with real users — migrate leaf routes first, keep the risky shared ones until last, and never attempt it as a single pull request.

Why does adding 'use client' to one file make everything a Client Component?

Because the directive marks a boundary, not a file. Every module imported by a Client Component also becomes part of the client bundle. A 'use client' near the top of a shared import graph — a context provider or a barrel file — cascades downward and can quietly convert most of your tree.

What is the hardest part of an App Router migration?

Caching, by most accounts. The App Router applies several caching layers with different invalidation rules, and the common production symptom is stale authenticated data — one user seeing another's cached page — which does not appear in local development.

Validate your idea against real demand

PainHunt scores hundreds of thousands of real user complaints by commercial potential — so you build what people already want.

Open the Pain Point Browser

Keep reading

Next.js App Router migration without breaking production | PainHunt