Introduction
Migrating from the Pages Router to the App Router is one of the most consequential decisions a Next.js team can make. The App Router introduces a fundamentally different mental model: Server Components are the default, layouts persist across navigations, data fetching moves to the component level, and the 'use client' directive replaces the implicit client-side rendering model. This guide provides a battle-tested migration strategy based on real-world experience migrating production applications with hundreds of routes, millions of monthly visitors, and complex data dependencies.
The migration isn't a simple file move—it requires rethinking data flow patterns, component boundaries, and caching strategies. Teams that treat it as a mechanical translation often end up with slower, more complex code. The key insight is that the App Router rewards different architectural patterns: component-level data fetching instead of page-level, composition instead of prop drilling, and streaming instead of blocking. This guide walks through each migration challenge with concrete solutions.
Understanding the Migration Scope
Before writing any code, assess the migration scope. The App Router and Pages Router coexist in the same project—Next.js resolves routes from app/ first, then falls back to pages/. This means you can migrate incrementally, route by route, without a big-bang rewrite.
Audit Your Current Application
Create a migration inventory by categorizing your routes:
// Migration audit template
const migrationAudit = {
// Simple routes: no custom layouts, minimal data fetching
simple: ['/about', '/contact', '/terms', '/privacy'],
// Data routes: use getServerSideProps or getStaticProps
dataRoutes: ['/blog/[slug]', '/products/[id]', '/users/[id]'],
// Layout routes: use _app.tsx patterns, nested layouts
layoutRoutes: ['/dashboard', '/settings', '/admin'],
// Interactive routes: heavy client-side state, real-time features
interactiveRoutes: ['/chat', '/editor', '/dashboard/charts'],
// API routes: no migration needed (API routes work in both)
apiRoutes: ['/api/auth', '/api/users', '/api/webhooks'],
}Key Differences to Understand
| Concept | Pages Router | App Router |
|---|---|---|
| Data fetching | getServerSideProps / getStaticProps | async Server Components + fetch |
| Layouts | _app.tsx (global only) | Nested layout.tsx per route |
| Loading states | Manual implementation | loading.tsx + Suspense |
| Error handling | _error.tsx / error.tsx | error.tsx per segment |
| Client components | Default | Requires 'use client' directive |
| Metadata | <Head> component | metadata export or <head> |
| Routing | useRouter from next/router | useRouter from next/navigation |
Migrating Data Fetching
The most impactful migration change is moving from page-level to component-level data fetching. In the Pages Router, getServerSideProps fetches all page data in one function, then passes it as props. In the App Router, each Server Component fetches its own data.
Before: Pages Router
// pages/products/[id].tsx
import { GetServerSideProps } from 'next'
export const getServerSideProps: GetServerSideProps = async ({ params }) => {
const product = await db.product.findUnique({
where: { id: params.id as string },
include: { reviews: { take: 10 }, category: true }
})
const recommendations = await db.product.findMany({
where: { categoryId: product.categoryId, id: { not: product.id } },
take: 6,
})
return { props: { product: serialize(product), recommendations: serialize(recommendations) } }
}
export default function ProductPage({ product, recommendations }) {
return (
<div>
<ProductDetail product={product} />
<RecommendationGrid products={recommendations} />
</div>
)
}After: App Router
// app/products/[id]/page.tsx
import { Suspense } from 'react'
export default async function ProductPage({ params }: { params: { id: string } }) {
return (
<div>
<Suspense fallback={<ProductDetailSkeleton />}>
<ProductDetail id={params.id} />
</Suspense>
<Suspense fallback={<RecommendationSkeleton />}>
<RecommendationGrid productId={params.id} />
</Suspense>
</div>
)
}
// Each component fetches its own data
async function ProductDetail({ id }: { id: string }) {
const product = await db.product.findUnique({
where: { id },
include: { reviews: { take: 10 }, category: true }
})
return <ProductDetailView product={product} />
}
async function RecommendationGrid({ productId }: { productId: string }) {
const product = await db.product.findUnique({ where: { id: productId } })
const recommendations = await db.product.findMany({
where: { categoryId: product.categoryId, id: { not: productId } },
take: 6,
})
return <RecommendationGridItems products={recommendations} />
}The key differences: (1) data fetching happens in components, not a page-level function; (2) each component streams independently; (3) the slow recommendations query doesn't block the fast product query.
Migrating Layouts and _app.tsx
The _app.tsx file is the most complex migration target because it handles global state, providers, layouts, and error boundaries.
Before: Pages Router _app.tsx
// pages/_app.tsx
import { ThemeProvider } from '@/providers/theme'
import { AuthProvider } from '@/providers/auth'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { MainLayout } from '@/layouts/main'
import { ErrorBoundary } from '@/components/error-boundary'
import '@/styles/globals.css'
const queryClient = new QueryClient()
export default function App({ Component, pageProps }) {
const getLayout = Component.getLayout || ((page) => <MainLayout>{page}</MainLayout>)
return (
<ErrorBoundary>
<QueryClientProvider client={queryClient}>
<AuthProvider>
<ThemeProvider>
{getLayout(<Component {...pageProps} />)}
</ThemeProvider>
</AuthProvider>
</QueryClientProvider>
</ErrorBoundary>
)
}After: App Router
// app/layout.tsx - Root layout
import { ThemeProvider } from '@/providers/theme'
import { AuthProvider } from '@/providers/auth'
import '@/styles/globals.css'
export const metadata = {
title: { template: '%s | My App', default: 'My App' },
description: 'My application description',
}
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<body>
<AuthProvider>
<ThemeProvider>
{children}
</ThemeProvider>
</AuthProvider>
</body>
</html>
)
}
// app/dashboard/layout.tsx - Dashboard-specific layout
import { DashboardSidebar } from '@/components/dashboard-sidebar'
import { QueryProvider } from '@/providers/query'
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
return (
<QueryProvider>
<div className="flex h-screen">
<DashboardSidebar />
<main className="flex-1 overflow-auto">{children}</main>
</div>
</QueryProvider>
)
}The getLayout pattern is eliminated—each route segment defines its own layout.tsx. Providers that don't need interactivity (like ThemeProvider) stay in the root layout. Providers that need client-side state (like QueryClientProvider) are wrapped in a Client Component wrapper.
Migrating Client Components
Components that use React hooks, event handlers, or browser APIs must be marked with 'use client'. The directive creates a boundary: the marked component and all its imports become client-side JavaScript.
The 'use client' Boundary Strategy
Minimize the 'use client' boundary to reduce client bundle size:
// ❌ Bad: 'use client' at the top of a large component tree
'use client'
import { useState } from 'react'
import { DataTable } from './data-table'
import { Chart } from './chart'
import { Filters } from './filters'
import { Header } from './header'
import { Sidebar } from './sidebar'
export function Dashboard() {
const [filters, setFilters] = useState({})
return (
<div>
<Header />
<Sidebar />
<Filters filters={filters} onChange={setFilters} />
<DataTable filters={filters} />
<Chart filters={filters} />
</div>
)
}
// âś… Good: 'use client' only on the interactive part
'use client'
import { useState } from 'react'
export function DashboardFilters() {
const [filters, setFilters] = useState({})
return (
<FilterContext.Provider value={{ filters, setFilters }}>
<FiltersUI />
</FilterContext.Provider>
)
}
// Server Component parent
import { DashboardFilters } from './dashboard-filters'
import { DataTable } from './data-table' // Server Component
import { Chart } from './chart' // Server Component
export default async function DashboardPage() {
return (
<div>
<DashboardFilters /> {/* Client Component */}
<DataTable /> {/* Server Component */}
<Chart /> {/* Server Component */}
</div>
)
}Migrating Routing and Navigation
The useRouter hook has a different API in the App Router:
// Pages Router
import { useRouter } from 'next/router'
function Component() {
const router = useRouter()
const { id } = router.query // Dynamic route params
router.push('/dashboard')
router.replace('/login')
router.prefetch('/products')
}
// App Router
import { useRouter, useParams, useSearchParams } from 'next/navigation'
function Component() {
const router = useRouter()
const params = useParams()
const searchParams = useSearchParams()
const id = params.id // Dynamic route params
router.push('/dashboard')
router.replace('/login')
router.prefetch('/products')
// New: router.refresh() to re-fetch Server Components
router.refresh()
}Migrating Dynamic Routes
// pages/blog/[slug].tsx (old)
export async function getStaticPaths() {
const posts = await db.post.findMany({ select: { slug: true } })
return {
paths: posts.map(p => ({ params: { slug: p.slug } })),
fallback: 'blocking',
}
}
export async function getStaticProps({ params }) {
const post = await db.post.findUnique({ where: { slug: params.slug } })
if (!post) return { notFound: true }
return { props: { post: serialize(post) }, revalidate: 3600 }
}
// app/blog/[slug]/page.tsx (new)
export async function generateStaticParams() {
const posts = await db.post.findMany({ select: { slug: true } })
return posts.map(p => ({ slug: p.slug }))
}
export default async function BlogPost({ params }: { params: { slug: string } }) {
const post = await db.post.findUnique({ where: { slug: params.slug } })
if (!post) notFound()
return <PostView post={post} />
}
export const revalidate = 3600 // ISR equivalentMigrating API Routes
API Routes in the pages/ directory migrate to Route Handlers in the app/ directory:
// pages/api/users.ts (old)
import type { NextApiRequest, NextApiResponse } from 'next'
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === 'GET') {
const users = await db.user.findMany()
return res.json(users)
}
if (req.method === 'POST') {
const user = await db.user.create({ data: req.body })
return res.status(201).json(user)
}
res.status(405).end()
}
// app/api/users/route.ts (new)
import { NextRequest, NextResponse } from 'next/server'
export async function GET() {
const users = await db.user.findMany()
return NextResponse.json(users)
}
export async function POST(request: NextRequest) {
const body = await request.json()
const user = await db.user.create({ data: body })
return NextResponse.json(user, { status: 201 })
}Real-World Use Cases
Use Case 1: E-Commerce Platform Migration (500+ routes)
A large e-commerce platform migrated 500+ routes over 6 months using a phased approach. Phase 1 migrated the product catalog (read-heavy, minimal interactivity) to Server Components, reducing client bundle by 60%. Phase 2 migrated the checkout flow (interactive, real-time validation) with careful 'use client' placement. Phase 3 migrated the admin dashboard (complex layouts, data tables) using nested layouts. Each phase was deployed independently, with both routers coexisting throughout.
Use Case 2: SaaS Application with Complex State
A SaaS application with global state (theme, auth, notifications, feature flags) migrated its providers to the App Router. The key challenge was that React Query and Zustand needed client-side hydration. The solution: wrap each provider in a 'use client' wrapper component, keeping the providers themselves client-side while the layout tree remains server-rendered.
Use Case 3: Content Platform with ISR
A content platform with 50,000 articles migrated from getStaticProps + revalidate to App Router Server Components with fetch({ next: { revalidate } }). The migration simplified the data fetching code by 40% and improved cache hit rates because Next.js now deduplicates fetch calls across components.
Best Practices for Production
-
Migrate bottom-up: Start with leaf pages (blog posts, product pages) before migrating complex layouts and shared components.
-
Run both routers simultaneously: The
app/andpages/directories coexist. Use this to migrate one route at a time without big-bang risk. -
Create a shared components library: Extract reusable components into a
components/directory that both routers can import. -
Audit
'use client'boundaries: UseNEXT_DEBUG_BUILD=1to see which components are client-rendered. Minimize the boundary to reduce client JavaScript. -
Test with React Strict Mode: Enable
reactStrictMode: trueto catch side effects that would cause issues in concurrent rendering. -
Monitor bundle size changes: Use
@next/bundle-analyzerto track how each migration phase affects client bundle size. -
Update testing strategy: Server Components require different testing approaches. Use
renderAsyncfor async components and mockfetchat the component level. -
Document migration decisions: Create an ADR (Architecture Decision Record) for each migration decision to help future team members understand the rationale.
Common Pitfalls and Solutions
| Pitfall | Impact | Solution |
|---|---|---|
| Migrating all routes at once | High risk, hard to debug issues | Migrate one route per PR with automated tests |
Using useRouter from wrong package | Import errors or missing functionality | Use next/navigation for App Router, next/router for Pages Router |
Forgetting 'use client' on interactive components | Components silently fail to hydrate | Add 'use client' directive at the top of files using hooks or event handlers |
| Server Component importing client-only library | Build error—library uses Node.js or browser APIs | Wrap the import in a Client Component or use dynamic import |
| Passing non-serializable props across Server/Client boundary | Runtime error—functions, dates, Maps can't be serialized | Ensure props are plain objects, arrays, strings, numbers, or booleans |
| Breaking SEO with client-side rendering | Pages rendered without metadata | Use metadata export in Server Components; verify with curl |
Performance Optimization
// Before: getServerSideProps fetching everything sequentially
export async function getServerSideProps() {
const user = await fetchUser() // 200ms
const posts = await fetchPosts() // 300ms
const comments = await fetchComments() // 400ms
// Total: 900ms sequential
return { props: { user, posts, comments } }
}
// After: Server Components with parallel streaming
export default async function Page() {
return (
<>
<Suspense fallback={<UserSkeleton />}>
<UserProfile /> {/* Streams in ~200ms */}
</Suspense>
<Suspense fallback={<PostsSkeleton />}>
<RecentPosts /> {/* Streams in ~300ms */}
</Suspense>
<Suspense fallback={<CommentsSkeleton />}>
<LatestComments /> {/* Streams in ~400ms */}
</Suspense>
</>
)
}
// All three fetch in parallel; total: 400ms (longest single fetch)Comparison with Alternatives
| Feature | Next.js App Router | Remix | Gatsby | Astro |
|---|---|---|---|---|
| Migration Path | Incremental (coexists with Pages) | Full rewrite | Plugin migration | Framework-agnostic |
| Server Components | Native React RSC | Similar (loader functions) | GraphQL | Island Architecture |
| Streaming | Built-in | Built-in | Not available | Partial |
| Learning Curve | Medium-high | Medium | Medium | Low |
| Ecosystem | Vercel, large community | Shopify, growing | Netlify, declining | Growing |
Testing Strategies
// Testing migrated components
import { render, screen } from '@testing-library/react'
import { ProductPage } from './page'
// Mock the database
jest.mock('@/lib/db', () => ({
db: {
product: {
findUnique: jest.fn().mockResolvedValue({
id: '1',
name: 'Test Product',
price: 29.99,
}),
},
},
}))
describe('ProductPage', () => {
it('renders product details', async () => {
// Server Components are async functions
const Component = await ProductPage({ params: { id: '1' } })
render(Component)
expect(screen.getByText('Test Product')).toBeInTheDocument()
expect(screen.getByText('$29.99')).toBeInTheDocument()
})
})Incremental Migration Strategies
Migrating from Pages Router to App Router doesn't require rewriting your entire application at once. Both routers coexist in the same project, allowing you to migrate route by route. Start with new features in the App Router while keeping existing pages in the Pages Router. The app directory takes precedence over pages for the same route path, so you can migrate individual routes without affecting others.
For each route being migrated, start by converting the page component to an async Server Component. Move data fetching from getServerSideProps or getStaticProps into the component body using fetch or direct database calls. Replace useRouter from next/router with useRouter from next/navigation, noting that the API differs slightly. Update any middleware or API routes to work with the new routing conventions.
Layout components are the highest-value migration target because they eliminate the need for _app.tsx and _document.tsx. Converting your root layout to app/layout.tsx gives you persistent layouts, nested layouts, and streaming by default. This single change often justifies the migration effort because it eliminates entire categories of bugs related to state preservation during navigation and layout flickering. Document your migration progress in a shared tracking document so the team can coordinate and avoid duplicate work.
Future Outlook
The App Router is the definitive future of Next.js. The Pages Router will continue to receive security patches and critical bug fixes, but all new features—Partial Prerendering, Server Actions, improved caching, Turbopack integration—are built exclusively for the App Router. Teams should plan their migration timeline with this in mind: the question isn't whether to migrate, but when and how fast.
Conclusion
Migrating to the App Router is a strategic investment in your application's architecture. The migration enables React Server Components (reducing client JavaScript by 50-70%), nested layouts (eliminating complex layout patterns), streaming SSR (improving Time to Interactive), and component-level data fetching (simplifying caching and state management).
Key takeaways:
- Migrate incrementally—both routers coexist, so there's no big-bang requirement
- Start with read-heavy, low-interactivity routes where Server Components provide the most benefit
- Minimize
'use client'boundaries to keep client bundles small - Replace
getServerSidePropswith component-levelfetchcalls in Server Components - Test each migration phase with bundle analysis and performance benchmarks
For the complete migration reference, consult the Next.js App Router migration guide and React Server Components documentation.