Migrating from Pages Router to App Router: A Practical Guide
A complete record of migrating a mid-sized Next.js project from Pages Router to App Router, covering route restructuring, data fetching patterns, layout strategies, and common pitfalls.
Why Migrate?
App Router is the future of Next.js, offering better layout systems, Server Components support, and more granular data fetching control.
Migration isn't about chasing trends — it's about gaining better architecture: nested layouts, streaming rendering, and smaller client bundles.
Our project is a mid-sized B2B admin dashboard with ~40 pages. Under Pages Router, global state and layout management was increasingly painful: every page needed manual Layout wrapping, data fetching logic scattered across getServerSideProps, hard to reuse.
After two weeks of evaluation and one week of implementation, we completed the migration. Here are the key steps and lessons learned.
Migration Steps
1. Route Restructuring
Gradually move files from `pages/` to `app/` directory, using `page.tsx` and `layout.tsx` to replace page components.
The strategy was **incremental** — Next.js allows `pages/` and `app/` to coexist. We migrated module by module, ensuring every step was reversible.
Start with the simplest pages to validate the build pipeline. Confirm middleware, env vars, and third-party libs work correctly before tackling complex pages.
2. Data Fetching
Shift from `getServerSideProps` / `getStaticProps` to async Server Components that fetch data directly.
This is the biggest mental model shift. In Pages Router, data fetching and UI rendering are separate; in App Router, components themselves are the data fetching boundary.
The most pleasant surprise: removing getServerSideProps dramatically improved readability — fetch data right where you use it, no prop drilling through layers.
3. Client Components
Add `"use client"` directive to interactive components, keeping as much of the component tree as Server Components.
Key principle: **Keep "use client" boundaries as small as possible**. Don't mark entire pages as client — push interactive logic to the smallest leaf components.
Pitfalls
params Became a Promise
In App Router, dynamic route params and searchParams became Promises in the latest versions — requires await. Easy to miss, but TypeScript types will catch it.
Metadata API
SEO config migrated from Head component to generateMetadata function. The upside: you can async fetch data for dynamic meta tags, like generating OG image descriptions from article content.
CSS-in-JS Compatibility
Some CSS-in-JS libraries (like styled-components) don't work in Server Components — need Client Component wrappers. We ultimately switched to Tailwind CSS, completely avoiding this issue.
If your project heavily relies on CSS-in-JS, verify RSC compatibility before migrating. This could be the biggest migration blocker.
Third-party Library Compatibility
Common libraries (date-fns, lodash) work fine in Server Components, but anything depending on browser APIs (chart libs, editors) must be used in Client Components.
Performance Comparison
We ran detailed benchmarks before and after:
The most significant improvement came from Server Components — display-only components no longer ship JavaScript to the client, dramatically reducing bundle size.
Results
~30% improvement in page load performance, cleaner code structure, and easier layout reuse. While migration has a learning curve, it's a worthwhile long-term investment.
If your project is still on Pages Router, consider using App Router for new features and incrementally migrating existing pages. No need to do everything at once.