Migrating from Next.js Pages Router to App Router: A Real Project Walkthrough
I migrated a 40-page Next.js site from the Pages Router to App Router in a single weekend. Here's exactly what broke, what surprised me, and the order of operations that made it painless.
Advertisement
The Next.js App Router has been stable since v13.4, yet most production codebases I've encountered are still on the Pages Router. The migration feels daunting, but it's more mechanical than creative. I recently moved a 40-page e-government portal from Pages to App Router and want to share the exact order of operations — and the gotchas that cost me hours.
Why Migrate at All?
- React Server Components: fetch data directly in components with zero client-side JS for the data layer
- Nested layouts: no more duplicating header/footer logic across _app.tsx and individual pages
- Streaming and Suspense: progressive page rendering out of the box
- Server Actions: form submissions and mutations without separate API routes
- Better code splitting: each segment is its own bundle
The Migration Order That Worked
The key insight is that Pages Router and App Router coexist. You don't rewrite everything at once. Start by creating the app/ directory alongside your pages/ directory. Next.js will resolve app/ routes first. Migrate leaf pages (no shared layout) first, then work your way up to routes that share complex layouts.
What Broke Immediately
- next/router → replace all useRouter from 'next/router' with useRouter from 'next/navigation'. The API is different — no pathname on the new router, use usePathname() separately.
- getServerSideProps and getStaticProps don't exist. Data fetching moves into async Server Components.
- Context providers must be Client Components. Wrap them in a 'use client' providers.tsx file and import into your root layout.
- next/head is gone. Use the Metadata API (export const metadata or generateMetadata) instead.
- Custom _app.tsx global CSS imports move to the root layout.tsx.
Data Fetching Pattern Change
// BEFORE (Pages Router)
export async function getStaticProps({ params }) {
const project = await fetchProject(params.slug);
return { props: { project } };
}
export default function ProjectPage({ project }) { ... }
// AFTER (App Router)
export default async function ProjectPage({ params }) {
const project = await fetchProject(params.slug); // runs on the server
return <ProjectDetail project={project} />;
}The Middleware Trap
If you're using next-intl or any middleware that rewrites URLs, make sure your middleware.ts is updated to the App Router pattern. The matcher config is the same, but the internal response API changed slightly. I spent two hours debugging why my locale redirects stopped working before realizing I was importing from the wrong next-intl entry point.
Final Verdict
The migration took a weekend for 40 pages. The result: a 30% reduction in client-side JavaScript, faster Time to First Byte on data-heavy pages, and dramatically cleaner code. The Server Component mental model takes a few days to internalize, but once it clicks, you'll never want to go back.
Advertisement