Migrating from Pages Router to App Router30 min read
Module P-17·30 min read
The coexistence model, page-by-page migration strategy, getServerSideProps→RSC, getStaticProps→generateStaticParams, API Routes→Route Handlers, next/router→next/navigation API differences, _app/_document→RootLayout providers, Middleware behaviour during partial migration, and the ten pitfalls that block every team mid-migration.
Who this is for: Teams sitting on a production Pages Router codebase who need to move to the App Router without a rewrite freeze. This module assumes you know the App Router's mental model from the earlier phases — Server Components, layout.tsx, Route Handlers — and now need the mapping table and the migration order that lets you ship the move page by page, in production, without a big-bang cutover.
The Coexistence Model
The single most important fact about this migration: the pages/ directory and the app/ directory can exist in the same project at the same time, and Next.js will route both.
text
Next.js resolves routing with one rule: if a route exists in both app/ and pages/, app/ wins. In practice you won't hit that conflict on purpose — you migrate a route by moving it, not by duplicating it — but it means you can create the new app/dashboard/page.tsx and confirm it renders correctly before deleting pages/dashboard.tsx, then delete the old file once you're satisfied. There's no environment flag, no dual-build step, no adapter package. It's the same Next.js server serving both trees from one build.
This is what makes route-by-route migration realistic. You do not need to touch pages/settings.tsx to migrate pages/dashboard.tsx. Each route migrates independently, and the two directories keep running side by side for as long as your migration takes — weeks or months on a large app is completely normal.
Two things stay global for as long as anything remains in pages/:
pages/_app.tsx and pages/_document.tsx still run, but only for routes still under pages/. They have no effect on routes under app/.
app/layout.tsx is required as soon as any route lives under app/, and it governs only app/ routes.
That means during the transition you are maintaining two parallel "shells" — one via _app/_document, one via RootLayout — and any provider, global CSS import, or <html>/<body> customization has to be present in both until pages/ is fully retired.
A Sane Migration Order
Next.js's own recommendation, which holds up in practice, is to migrate leaves before roots: start with the pages that have the fewest incoming dependencies (a settings page, a static marketing page) and get more foundational as you gain confidence, saving the routes that share the most infrastructure — such as the ones behind global auth or a layout wrapper every other page reuses — for later. A workable sequence:
Set up app/layout.tsx with the bare minimum: <html>, <body>, and whatever your _document.tsx was doing (fonts, lang attribute).
Migrate leaf pages first — pages with no nested layouts, few dependencies, and low traffic. A /about or /settings page is a good first candidate.
Migrate API routes to Route Handlers as you touch the pages that call them, not all at once — there's no requirement to migrate pages/api/* before pages/*.tsx, or vice versa.
Migrate shared layout structure (nav bars, sidebars) once you understand which pages will share an app/(group)/layout.tsx.
Migrate the global providers from _app.tsx into app/layout.tsx last, once most consuming pages are already Server/Client Components under app/ — this is usually the highest-risk step because it touches every route.
Delete pages/_app.tsx, pages/_document.tsx, and the pages/ directory once nothing remains in it.
Middleware is worth calling out here even though it isn't a step: it needs no migration at all. See the dedicated section below.
getServerSideProps → Server Component Data Fetching
getServerSideProps (GSSP) ran on every request, on the server, and passed its return value to the page as props. In the App Router, a Server Component is the server-side data-fetching layer — there's no separate function, no props handoff, no serialization boundary between "data function" and "component."
Pages Router:
tsx
App Router:
tsx
The mapping:
GSSP context
App Router equivalent
context.query
the searchParams prop (a Promise in Next.js 15 — must be awaited)
context.params (dynamic routes)
the params prop (also a Promise in Next.js 15)
context.req.cookies
await cookies() from next/headers
context.req.headers
await headers() from next/headers
return { props }
just return JSX — no wrapper object
return { notFound: true }
call notFound() from next/navigation
return { redirect: { destination } }
call redirect() from next/navigation
There is no per-request "props" concept to preserve — the Server Component runs on the server for every request by default (unless the route is statically rendered), so awaiting your data source directly inside the component is the per-request fetch that GSSP used to do.
getStaticProps (GSSP-static) built props at build time; getStaticPaths told Next.js which dynamic paths to prebuild. In the App Router these two responsibilities split differently: generateStaticParams replaces getStaticPaths (it only enumerates the paths), and the page component itself does the fetching that getStaticProps used to do.
Pages Router:
tsx
App Router:
tsx
Notes on the mapping:
generateStaticParams returns an array of plain objects matching the dynamic segment names — no { params: {...} } wrapper, unlike getStaticPaths.
There's no direct per-page equivalent of fallback: 'blocking' | true | false. By default, paths not returned by generateStaticParams are still rendered on demand at request time and cached going forward — closest to fallback: 'blocking'. To render only the paths you enumerated and 404 everything else, set export const dynamicParams = false in the same file.
revalidate moves from a per-request return value to a route-segment config export (export const revalidate = <seconds>), or you can pass a { next: { revalidate } } option directly to a fetch() call for per-request granularity.
API Routes → Route Handlers
pages/api/*.ts files exported a single default function and switched on req.method. app/*/route.ts files export one named function per HTTP method instead.
Pages Router:
ts
App Router:
ts
There is no manual 405 handling to write — Route Handlers automatically respond 405 Method Not Allowed for any HTTP method you didn't export. The file location convention also changes: it's route.ts (or .js), not an arbitrarily-named file, and it lives alongside page.tsx files under the matching path segment — app/api/users/[id]/route.ts responds at /api/users/:id, exactly like the old pages/api/users/[id].ts did.
req.query and req.body don't exist on the App Router's NextRequest. Query params come from request.nextUrl.searchParams; the body comes from await request.json() (or .formData(), .text(), depending on content type).
next/router → next/navigation
This is the change that breaks the most code silently, because both modules export a hook called useRouter() — but they are not compatible, and TypeScript won't always catch the difference at the call site if you're accessing a property that happens to exist on neither type's autocomplete you're looking at.
Pages Router (next/router): one hook does everything.
tsx
App Router (next/navigation): the same responsibilities are split across four hooks, and none of them merge params and query string together.
tsx
Key differences to actually internalize, not just skim:
next/router (useRouter())
next/navigation
router.pathname — the route pattern (/products/[id])
usePathname() — the resolved path (/products/42), no pattern placeholders
router.query — params + query string merged into one object
useParams() for dynamic segments, useSearchParams() for the query string — kept separate
router.asPath
closest equivalent is pathname + searchParams.toString() combined yourself
router.push(), router.replace(), router.back()
same names, same purpose, but no second options.shallow argument — shallow routing isn't a concept in the App Router
does not exist. There is no event emitter on the App Router's useRouter().
available in any component
useRouter, usePathname, useSearchParams, and useParams all require the component to be a Client Component ('use client') — none of them work in a Server Component
The missing router.events is the one that catches teams off guard, because it's usually load-bearing for things like page-view analytics or top-loading-bar progress indicators. There's no direct replacement hook. The two common substitutes: track navigation via a usePathname()/useSearchParams() combo in a Client Component wrapped around your children (comparing values across renders with useEffect), or move page-view tracking into <Link>onClick handlers and initial-load tracking into the root layout. Neither is a drop-in equivalent — treat this as an actual rewrite of that piece of code, not a search-and-replace.
Also note that useSearchParams() opts the component subtree into client-side rendering up to the nearest Suspense boundary — if you read search params in a Client Component that's supposed to be statically rendered, wrap it in <Suspense> or the build will warn (and in some cases de-optimize the whole route to dynamic rendering).
_app.tsx / _document.tsx → RootLayout
_app.tsx wrapped every page and was where global CSS, context providers, and persistent layout lived. _document.tsx controlled the raw <html>/<body> shell and only ran on the server. app/layout.tsx merges both responsibilities into a single Server Component.
Pages Router:
tsx
tsx
App Router:
tsx
A few things worth being precise about:
RootLayout is a Server Component by default. If your provider (theme context, a client-side query cache, etc.) needs useState, useEffect, or React Context that consumers read via a hook, the provider component itself needs 'use client' at its own definition — but you still import and render it from the Server Component RootLayout directly. Providers "sandwich" fine: a Server Component parent rendering a Client Component provider around Server Component children is the standard, supported pattern.
Global CSS can only be imported from the root layout (or another top-level layout/page under app/), not from arbitrary components — same restriction that existed for _app.tsx, just enforced at a different file.
There's no pageProps concept. Each page.tsx fetches its own data; nothing is threaded through from a parent "app" wrapper.
metadata (or generateMetadata for dynamic values) replaces manually writing <head> tags in _document.tsx or using next/head — see the next section for why next/head specifically stops working.
Middleware During Partial Migration
This is the good news in this module: Middleware requires zero changes during migration.middleware.ts operates on the incoming Request before Next.js decides whether the matched route lives under app/ or pages/ — it is router-agnostic by design. A matcher like /dashboard/:path* fires identically whether /dashboard is currently served by pages/dashboard.tsx or app/dashboard/page.tsx.
Practically, this means auth checks, redirects, and header-forwarding logic you've already written in Middleware keep working untouched as you migrate the routes behind them. The one thing to double check is that your matcher's route list stays in sync with reality as paths move — if a migrated route changes its URL segment structure (rare, but possible during a directory reorganization), update the matcher accordingly. The Middleware logic itself never needs to know which router served the page.
The Pitfalls That Actually Block Migrations
These are the specific, recurring issues that stall a Pages-to-App migration mid-way, roughly in the order teams hit them.
1. next/head silently does nothing in the App Router. If you migrate a page and leave a <Head> component from next/head inside it, it won't throw — it just won't render anything into the document head. Replace it with the metadata object export or generateMetadata() async function.
2. Client-only libraries need an explicit 'use client' boundary, and it has to be at the right level. Any component using useState, useEffect, browser-only APIs (window, localStorage), or a library that assumes a DOM (many chart and rich-text-editor libraries) needs 'use client' at the top of the file. The common mistake isn't forgetting the directive — it's putting it on the entire page.tsx, which forces the whole tree client-side, when the fix should be isolating just the interactive leaf component and keeping the page itself a Server Component.
3. Reading cookies/session state moves from a request object to an async function call.req.cookies (GSSP) or req.cookies (API routes) becomes await cookies() from next/headers — and it can only be called from Server Components, Route Handlers, and Server Actions, not from Client Components. If your auth helper reads cookies and is imported into a Client Component, that import will fail; the helper needs a server-only and client-side path, or needs to be called higher up and passed down as data.
4. _error.tsx doesn't map cleanly onto one file — it maps onto two.pages/_error.tsx handled both rendering errors and custom status-code pages (like a custom 500). The App Router splits this: error.tsx (a Client Component boundary, catches errors in its segment and below, does not catch errors in the layout that renders it) and global-error.tsx (catches errors in the root layout itself, and must render its own <html>/<body> since it replaces the root layout when triggered). A single _error.tsx handler doesn't have a single-file replacement.
5. Custom 404s move from a page file to a function call.pages/404.tsx becomes app/not-found.tsx, but you also need to actually trigger it — call notFound() from next/navigation inside the Server Component, rather than the Pages Router's return { notFound: true }.
6. Catch-all route syntax is unchanged, but the file location convention isn't.[...slug].tsx and [[...slug]].tsx (optional catch-all) work exactly the same way inside app/ — same bracket syntax — but the file must be named page.tsx inside a folder named for the segment: pages/blog/[...slug].tsx becomes app/blog/[...slug]/page.tsx, not app/blog/[...slug].tsx.
7. The <Image> component's required props changed subtly. The App Router's next/image still needs width/height or fill, but if you're migrating a page that relied on Pages Router defaults around lazy loading or the deprecated layout/objectFit props from older next/image versions, those props were already removed well before Next.js 13 — if your Pages Router code predates that removal, you're fixing two migrations at once (old Image API → new Image API, then Pages → App), and it's worth doing the Image API cleanup as a separate, earlier pass so it doesn't get conflated with App Router bugs.
8. getInitialProps, if anything still uses it, has no App Router equivalent at all. Unlike GSSP/GSSP-static, getInitialProps (usually left over in a custom _app.tsx or _error.tsx for older codebases) predates the split between static and server rendering and doesn't map onto a single App Router primitive. Code still relying on it needs to be re-architected onto explicit Server Component data fetching, not mechanically translated.
9. Layout state doesn't automatically persist across navigations the way _app.tsx state did. In the Pages Router, <Component {...pageProps} /> re-rendered inside a _app.tsx that itself didn't remount, so state held in _app.tsx (a shopping cart count, a sidebar's open/closed state) survived page navigation for free. In the App Router, a Client Component holding that state needs to live in a layout.tsx that's shared across the routes where the state should persist — moving it into an individual page.tsx will reset it on every navigation, because page.tsx remounts on route change while a shared layout.tsx does not.
10. Two build outputs, two mental models, until the migration is actually done. It's tempting to consider the migration "mostly done" once most traffic goes through app/, but every remaining pages/ file still needs _app.tsx/_document.tsx maintained, still uses the old data-fetching functions, and still needs its own testing pass. Teams that stall here tend to do so because the last 10% of routes are the highest-traffic, most stateful ones (exactly the routes the migration order above tells you to save for last) — budget for that tail explicitly rather than assuming it'll go as fast as the leaf pages did.
Next: AI Integration and Streaming Route Handlers →
Knowledge Check
During a Pages Router to App Router migration, what determines which router handles a request if a route path exists as both pages/dashboard.tsx and app/dashboard/page.tsx?
A component calls useSearchParams() from next/navigation to read a ?sort= query parameter. What must be true for this to work?
A team migrates pages/blog/[slug].tsx to app/blog/[slug]/page.tsx and moves the data fetching from getStaticProps into the page component itself. What replaces getStaticPaths for pre-rendering the known set of blog slugs at build time?
Test your knowledge with more question sets
Sign in to access a wider variety of questions and get notified when new practice sets are added to this module.