Module P-17·20 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.

Written by Jatin Jain Saraf · Architect of SupraScan

P-17 — Migrating from Pages Router to App Router

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.

my-app/
├── app/
│   ├── layout.tsx          ← new RootLayout, required once app/ exists
│   ├── dashboard/
│   │   └── page.tsx        ← migrated route
│   └── api/
│       └── users/
│           └── route.ts    ← migrated API route
├── pages/
│   ├── _app.tsx            ← still runs for everything still in pages/
│   ├── _document.tsx
│   ├── settings.tsx        ← not migrated yet
│   └── api/
│       └── legacy.ts       ← not migrated yet

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:

  1. Set up app/layout.tsx with the bare minimum: <html>, <body>, and whatever your _document.tsx was doing (fonts, lang attribute).
  2. Migrate leaf pages first — pages with no nested layouts, few dependencies, and low traffic. A /about or /settings page is a good first candidate.
  3. 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.
  4. Migrate shared layout structure (nav bars, sidebars) once you understand which pages will share an app/(group)/layout.tsx.
  5. 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.
  6. 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:

// pages/dashboard.tsx
import type { GetServerSideProps } from 'next';
 
interface DashboardProps {
  user: { id: string; name: string };
  stats: { visits: number };
}
 
export const getServerSideProps: GetServerSideProps<DashboardProps> = async (context) => {
  const { req, query } = context;
  const userId = query.userId as string;
 
  const [user, stats] = await Promise.all([
    fetchUser(userId, req.cookies.session),
    fetchStats(userId),
  ]);
 
  if (!user) {
    return { notFound: true };
  }
 
  return { props: { user, stats } };
};
 
export default function Dashboard({ user, stats }: DashboardProps) {
  return <div>{user.name} — {stats.visits} visits</div>;
}

App Router:

// app/dashboard/page.tsx
import { cookies } from 'next/headers';
import { notFound } from 'next/navigation';
 
export default async function DashboardPage({
  searchParams,
}: {
  searchParams: Promise<{ userId?: string }>;
}) {
  const { userId } = await searchParams;
  const cookieStore = await cookies();
  const session = cookieStore.get('session')?.value;
 
  const [user, stats] = await Promise.all([
    fetchUser(userId, session),
    fetchStats(userId),
  ]);
 
  if (!user) {
    notFound();
  }
 
  return <div>{user.name} — {stats.visits} visits</div>;
}

The mapping:

GSSP contextApp Router equivalent
context.querythe 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.cookiesawait cookies() from next/headers
context.req.headersawait 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 / getStaticPaths → generateStaticParams

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:

// pages/blog/[slug].tsx
import type { GetStaticPaths, GetStaticProps } from 'next';
 
export const getStaticPaths: GetStaticPaths = async () => {
  const posts = await getAllPostSlugs();
  return {
    paths: posts.map((slug) => ({ params: { slug } })),
    fallback: 'blocking',
  };
};
 
export const getStaticProps: GetStaticProps = async ({ params }) => {
  const post = await getPostBySlug(params!.slug as string);
  if (!post) return { notFound: true };
  return { props: { post }, revalidate: 3600 };
};
 
export default function BlogPost({ post }: { post: Post }) {
  return <Article post={post} />;
}

Sign in to keep reading

The rest of this module is free — sign in with Google to unlock it and track your progress.

Sign in & Register

Discussion

0

Join the discussion

Loading comments...

© 2026 Jatin Jain Saraf (JJS). All rights reserved.