Next.js

Next.js App Router Migration: Lessons from 20 Production Apps

MA
Marco Alves
VP of Engineering · · 9 min read

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

  1. Global state accessed via _app.tsx. Context providers wrapping the whole app need to move into a root layout.tsx, but any provider using client-only APIs must be wrapped in a client component boundary — you cannot put 'use client' directly on layout.tsx if you still want server-rendered children.
  2. useRouter behavior differences. The App Router's useRouter from next/navigation has a smaller API than the Pages Router's next/router — no more router.query for reading search params; you now use useSearchParams. Every component reading query params needs a manual pass.
  3. Custom getInitialProps on _document.tsx or _error.tsx. These have no direct App Router equivalent. _error.tsx becomes error.tsx (a client component, by requirement) and _document.tsx customizations typically move into app/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.

#Next.js#App Router#migration#web development
Keep Reading

More from the blog

Let's build something reliable, together.

Tell us about your project and get a free technical consultation within one business day — no obligation, no sales pressure.