Next.js App Router Migration: Lessons from 20 Production Apps
Every App Router migration we've run into trouble with had the same root cause: the team tried to convert the whole app in one pull request. Every migration that went smoothly followed roughly the same incremental order. This isn't a "10 things I learned" listicle — it's the literal checklist our engineering leads use before starting a client migration.
Start with a routes inventory, not a routes rewrite
Before touching code, list every route in the Pages Router app alongside three columns: does it fetch data server-side (getServerSideProps), statically (getStaticProps), or client-side only? This single artifact tells you migration order. Static and server-rendered routes with simple data needs migrate first and safely. Routes with complex getInitialProps logic, custom _app.tsx behavior, or heavy third-party SDK usage migrate last, once your team has more App Router experience under its belt.
Next.js supports both routers at once — use that
App Router and Pages Router can coexist in the same project. The app/ and pages/ directories are resolved independently, with app/ taking priority on route conflicts. This means you can migrate route-by-route in production, behind normal code review, without a big-bang cutover or a long-lived feature branch that drifts from main.
app/
dashboard/
page.tsx ← migrated
pages/
settings.tsx ← not yet migrated, still works
api/
webhook.ts ← API routes often migrate last
The three things that break every time
- Global state accessed via
_app.tsx. Context providers wrapping the whole app need to move into a rootlayout.tsx, but any provider using client-only APIs must be wrapped in a client component boundary — you cannot put'use client'directly onlayout.tsxif you still want server-rendered children. useRouterbehavior differences. The App Router'suseRouterfromnext/navigationhas a smaller API than the Pages Router'snext/router— no morerouter.queryfor reading search params; you now useuseSearchParams. Every component reading query params needs a manual pass.- Custom
getInitialPropson_document.tsxor_error.tsx. These have no direct App Router equivalent._error.tsxbecomeserror.tsx(a client component, by requirement) and_document.tsxcustomizations typically move intoapp/layout.tsx's<html>/<head>structure.
Data fetching: resist the urge to keep your fetching library "as-is"
Teams migrating from SWR or React Query often want to keep using them unchanged for expediency. That's fine short-term, but it forfeits the App Router's biggest win: fetching data directly in server components with native fetch caching, which eliminates client-side loading spinners for content that doesn't need to be interactive. We recommend a two-phase approach — migrate the route structure first with existing data-fetching libraries intact, then do a second pass moving read-only data fetches into server components once the route migration has stabilized.
Testing the migration without a big-bang QA cycle
Because both routers run side by side, you can ship each migrated route independently and monitor it in production with real traffic before moving to the next one. Our standard rollout for a client migration:
- Migrate and ship 1–2 low-traffic routes first as a smoke test
- Monitor Core Web Vitals and error rates for 3–5 days
- Migrate the remaining routes in batches of 3–5, ordered by data-fetching complexity (simplest first)
- Migrate API routes to Route Handlers last, since they're the least visible to end users if something regresses
The metadata API is worth migrating for on its own
Independent of Server Components, the App Router's built-in generateMetadata function replaces the next-seo or hand-rolled <Head> juggling most Pages Router apps accumulate over time. For SEO-heavy marketing sites, this alone is often worth the migration effort — canonical URLs, Open Graph tags, and structured data become colocated with the route instead of scattered across custom Head components.
What we tell clients about timeline
For a mid-sized app (30–60 routes), budget 6–10 weeks for a careful, incremental migration with a small dedicated team — not because the App Router itself is complex, but because auditing each route's data-fetching pattern and testing it properly takes real engineering time. Any estimate significantly shorter than that is usually skipping the testing step, which is exactly the step that prevents a production incident three weeks after the "migration is done" is declared.