Migrating from Next.js Pages Router to App Router: A Step-by-Step Guide
The Next.js App Router represents a fundamental shift in how we structure React applications, moving from file-based routing to a more flexible, component-drive...
Osmoto Team
Senior Software Engineer

The Next.js App Router represents a fundamental shift in how we structure React applications, moving from file-based routing to a more flexible, component-driven approach. While the migration from Pages Router to App Router might seem straightforward on the surface, the reality involves navigating significant architectural changes, handling breaking changes in data fetching patterns, and restructuring your entire application layout system.
Many development teams delay this migration due to concerns about breaking existing functionality, especially in production applications with complex routing structures, authentication flows, and API integrations. However, the benefits—including improved performance through React Server Components, better developer experience with colocation, and enhanced SEO capabilities—make this migration essential for long-term maintainability and scalability.
This guide walks through the complete migration process, covering everything from initial project setup to handling edge cases that commonly trip up developers during the transition.
Understanding the Architectural Differences
Before diving into the migration steps, it's crucial to understand how App Router fundamentally differs from Pages Router. The Pages Router uses a file-based system where each file in the pages directory automatically becomes a route. App Router, introduced in Next.js 13, uses a folder-based system where each folder can contain multiple files with specific naming conventions.
Key Structural Changes
In Pages Router, your structure might look like:
pages/
index.js // Route: /
about.js // Route: /about
blog/
index.js // Route: /blog
[slug].js // Route: /blog/[slug]
api/
users.js // API Route: /api/users
App Router transforms this into:
app/
page.js // Route: /
about/
page.js // Route: /about
blog/
page.js // Route: /blog
[slug]/
page.js // Route: /blog/[slug]
api/
users/
route.js // API Route: /api/users
The most significant change is that App Router requires explicit page.js files to create accessible routes. This allows you to colocate components, styles, and tests within the same directory without accidentally creating routes.
Data Fetching Philosophy Shift
Pages Router relies heavily on special functions like getServerSideProps, getStaticProps, and getInitialProps. App Router embraces React Server Components, allowing you to fetch data directly in your components using standard async/await syntax.
// Pages Router approach export async function getServerSideProps(context) { const res = await fetch(`https://api.example.com/posts/${context.params.id}`) const post = await res.json() return { props: { post } } } export default function Post({ post }) { return <div>{post.title}</div> }
// App Router approach async function Post({ params }) { const res = await fetch(`https://api.example.com/posts/${params.id}`) const post = await res.json() return <div>{post.title}</div> }
Pre-Migration Assessment and Planning
Inventory Your Current Structure
Start by creating a comprehensive inventory of your existing Pages Router structure. Document:
- All routes and their corresponding files
- Data fetching patterns used (
getServerSideProps,getStaticProps,getInitialProps) - Custom App and Document components (
_app.js,_document.js) - API routes and their functionality
- Middleware implementations
- Custom error pages (
_error.js,404.js)
Create a migration checklist that maps each Pages Router file to its App Router equivalent. This prevents overlooking critical functionality during the transition.
Identify Breaking Changes
Several patterns from Pages Router don't have direct equivalents in App Router:
getInitialProps: No direct equivalent; requires restructuring to use Server Components or client-side fetching- Custom
_app.jslogic: Must be moved to layout components or providers _document.jscustomizations: Handled throughlayout.jsfiles and metadata API- Automatic static optimization: Replaced with explicit static/dynamic rendering decisions
Version Compatibility Check
Ensure you're running Next.js 13.4 or later for stable App Router support. Check your dependencies for compatibility:
npx next@latest npm list react react-dom
Some third-party packages may not be compatible with React Server Components. Identify these early and plan alternatives or client-side implementations.
Step-by-Step Migration Process
Step 1: Enable App Router
First, create the app directory alongside your existing pages directory. Next.js supports running both routers simultaneously during migration.
mkdir app
Update your next.config.js to explicitly enable the experimental features if you're on an older version:
/** @type {import('next').NextConfig} */ const nextConfig = { experimental: { appDir: true, // Only needed for Next.js < 13.4 }, } module.exports = nextConfig
Step 2: Create Root Layout
Every App Router application requires a root layout. Create app/layout.js:
import './globals.css' export const metadata = { title: 'Your App Name', description: 'Your app description', } export default function RootLayout({ children, }: { children: React.ReactNode }) { return ( <html lang="en"> <body>{children}</body> </html> ) }
This replaces the functionality of _document.js and provides the basic HTML structure for your application.
Step 3: Migrate Your Homepage
Start with your main pages/index.js file. Create app/page.js:
// If your original page had getServerSideProps async function getData() { const res = await fetch('https://api.example.com/data') if (!res.ok) { throw new Error('Failed to fetch data') } return res.json() } export default async function HomePage() { const data = await getData() return ( <main> <h1>Welcome</h1> {/* Your existing JSX */} </main> ) }
Step 4: Handle Global State and Providers
If your _app.js included providers (Redux, Theme, Auth), create a providers component:
// app/providers.tsx 'use client' import { ThemeProvider } from 'next-themes' import { AuthProvider } from '@/lib/auth' export function Providers({ children }: { children: React.ReactNode }) { return ( <ThemeProvider> <AuthProvider> {children} </AuthProvider> </ThemeProvider> ) }
Then wrap your root layout:
// app/layout.tsx import { Providers } from './providers' export default function RootLayout({ children, }: { children: React.ReactNode }) { return ( <html lang="en"> <body> <Providers> {children} </Providers> </body> </html> ) }
Step 5: Migrate Dynamic Routes
Dynamic routes require careful attention to parameter handling. Convert pages/blog/[slug].js:
// app/blog/[slug]/page.tsx interface Props { params: { slug: string } } async function getPost(slug: string) { const res = await fetch(`https://api.example.com/posts/${slug}`) if (!res.ok) { throw new Error('Failed to fetch post') } return res.json() } export default async function BlogPost({ params }: Props) { const post = await getPost(params.slug) return ( <article> <h1>{post.title}</h1> <div>{post.content}</div> </article> ) } // Generate metadata dynamically export async function generateMetadata({ params }: Props) { const post = await getPost(params.slug) return { title: post.title, description: post.excerpt, } }
Step 6: Convert API Routes
API routes follow a similar folder structure but use route.js files:
// app/api/users/route.ts import { NextRequest, NextResponse } from 'next/server' export async function GET(request: NextRequest) { const users = await fetchUsers() return NextResponse.json(users) } export async function POST(request: NextRequest) { const body = await request.json() const user = await createUser(body) return NextResponse.json(user, { status: 201 }) }
For dynamic API routes, the folder structure changes:
// app/api/users/[id]/route.ts export async function GET( request: NextRequest, { params }: { params: { id: string } } ) { const user = await fetchUser(params.id) return NextResponse.json(user) }
Handling Data Fetching Migration
Server-Side Rendering Migration
The most complex part of migration involves converting data fetching patterns. Here's how to handle different scenarios:
Simple getServerSideProps conversion:
// Before (Pages Router) export async function getServerSideProps(context) { const { id } = context.params const user = await fetchUser(id) return { props: { user } } } // After (App Router) async function UserPage({ params }: { params: { id: string } }) { const user = await fetchUser(params.id) return <UserProfile user={user} /> }
Handling redirects and not found:
// App Router with error handling async function UserPage({ params }: { params: { id: string } }) { const user = await fetchUser(params.id) if (!user) { notFound() // Triggers 404 page } if (user.isPrivate && !user.canAccess) { redirect('/login') // Server-side redirect } return <UserProfile user={user} /> }
Static Generation Migration
Converting getStaticProps and getStaticPaths:
// Before (Pages Router) export async function getStaticPaths() { const posts = await fetchAllPosts() const paths = posts.map(post => ({ params: { slug: post.slug } })) return { paths, fallback: 'blocking' } } export async function getStaticProps({ params }) { const post = await fetchPost(params.slug) return { props: { post }, revalidate: 60 } } // After (App Router) export async function generateStaticParams() { const posts = await fetchAllPosts() return posts.map(post => ({ slug: post.slug })) } async function BlogPost({ params }: { params: { slug: string } }) { const post = await fetchPost(params.slug) return <PostContent post={post} /> } // Configure revalidation export const revalidate = 60
Client-Side Data Fetching
For client-side data fetching, you'll need to mark components with 'use client':
'use client' import { useState, useEffect } from 'react' export default function ClientDataComponent() { const [data, setData] = useState(null) useEffect(() => { fetch('/api/data') .then(res => res.json()) .then(setData) }, []) if (!data) return <div>Loading...</div> return <div>{/* Render data */}</div> }
Common Migration Pitfalls and Solutions
Pitfall 1: Mixing Server and Client Components
One of the most common issues is attempting to use client-side features in Server Components:
// ❌ This will cause an error export default async function ServerComponent() { const [state, setState] = useState(null) // Error: useState in Server Component const data = await fetchData() return <div onClick={() => setState(data)}>Click me</div> // Error: onClick in Server Component }
Solution: Separate server and client concerns:
// ✅ Server Component async function ServerComponent() { const data = await fetchData() return <ClientComponent initialData={data} /> } // ✅ Client Component 'use client' function ClientComponent({ initialData }) { const [state, setState] = useState(initialData) return <div onClick={() => setState(newData)}>Click me</div> }
Pitfall 2: Incorrect Error Boundary Placement
App Router requires explicit error boundaries for each route segment:
// app/dashboard/error.tsx 'use client' export default function Error({ error, reset, }: { error: Error & { digest?: string } reset: () => void }) { return ( <div> <h2>Something went wrong!</h2> <button onClick={reset}>Try again</button> </div> ) }
Pitfall 3: Metadata Configuration Issues
Forgetting to properly configure metadata can hurt SEO:
// ✅ Static metadata export const metadata = { title: 'My Page', description: 'Page description', } // ✅ Dynamic metadata export async function generateMetadata({ params }) { const post = await fetchPost(params.id) return { title: post.title, description: post.excerpt, openGraph: { title: post.title, description: post.excerpt, images: [post.image], }, } }
Pitfall 4: Route Handler Parameter Access
API route parameters are accessed differently:
// ❌ Wrong way export async function GET(req) { const { id } = req.query // This doesn't exist } // ✅ Correct way export async function GET( request: NextRequest, { params }: { params: { id: string } } ) { const id = params.id const searchParams = request.nextUrl.searchParams const query = searchParams.get('query') }
Testing Your Migration
Automated Testing Strategy
Create a comprehensive testing plan to ensure your migration doesn't break existing functionality:
// __tests__/migration.test.js import { render, screen } from '@testing-library/react' import HomePage from '../app/page' // Mock fetch for server components global.fetch = jest.fn() describe('Migration Tests', () => { it('renders homepage correctly', async () => { fetch.mockResolvedValue({ ok: true, json: async () => ({ data: 'test' }) }) render(await HomePage()) expect(screen.getByText('Welcome')).toBeInTheDocument() }) })
Performance Validation
Compare performance metrics before and after migration:
# Run Lighthouse audits npx lighthouse http://localhost:3000 --output=json --output-path=./before-migration.json # After migration npx lighthouse http://localhost:3000 --output=json --output-path=./after-migration.json
Monitor key metrics:
- First Contentful Paint (FCP)
- Largest Contentful Paint (LCP)
- Time to Interactive (TTI)
- Bundle size changes
Best Practices for App Router Migration
1. Gradual Migration Strategy
Don't attempt to migrate everything at once. Start with:
- Simple static pages
- Pages with basic data fetching
- Complex dynamic routes
- API routes with dependencies
- Pages with complex client-side interactions
2. Leverage Colocated Files
Take advantage of App Router's file colocation:
app/
dashboard/
page.tsx // Route component
loading.tsx // Loading UI
error.tsx // Error boundary
not-found.tsx // 404 page
components/ // Page-specific components
chart.tsx
table.tsx
styles/ // Page-specific styles
dashboard.css
3. Optimize Server Component Usage
Maximize the benefits of Server Components:
// ✅ Keep data fetching in Server Components async function ProductList() { const products = await fetchProducts() return ( <div> {products.map(product => ( <ProductCard key={product.id} product={product} /> ))} </div> ) } // ✅ Use Client Components only when necessary 'use client' function ProductCard({ product }) { const [liked, setLiked] = useState(false) return ( <div> <h3>{product.name}</h3> <button onClick={() => setLiked(!liked)}> {liked ? '❤️' : '🤍'} </button> </div> ) }
4. Implement Proper Error Handling
Create a robust error handling strategy:
// app/error.tsx - Global error boundary 'use client' export default function GlobalError({ error, reset, }: { error: Error & { digest?: string } reset: () => void }) { useEffect(() => { // Log error to monitoring service console.error('Global error:', error) }, [error]) return ( <html> <body> <div className="error-container"> <h2>Something went wrong!</h2> <button onClick={reset}>Try again</button> </div> </body> </html> ) }
5. Configure Proper Caching
Understand and configure caching behavior:
// Static rendering (default) export default async function StaticPage() { const data = await fetch('https://api.example.com/static-data') return <div>{data}</div> } // Dynamic rendering export const dynamic = 'force-dynamic' export default async function DynamicPage() { const data = await fetch('https://api.example.com/dynamic-data') return <div>{data}</div> } // Revalidated static rendering export const revalidate = 3600 // Revalidate every hour export default async function RevalidatedPage() { const data = await fetch('https://api.example.com/data') return <div>{data}</div> }
Post-Migration Optimization
Once your migration is complete, focus on optimization opportunities unique to App Router:
Bundle Size Optimization
App Router enables better code splitting through Server Components:
// Heavy client-side library only loads when needed 'use client' import { Chart } from 'heavy-chart-library' export default function ChartComponent({ data }) { return <Chart data={data} /> }
SEO Enhancements
Leverage the improved metadata API:
export async function generateMetadata({ params, searchParams }) { const product = await fetchProduct(params.id) return { title: `${product.name} | Your Store`, description: product.description, keywords: product.tags.join(', '), openGraph: { title: product.name, description: product.description, images: product.images.map(img => ({ url: img.url, width: img.width, height: img.height, alt: img.alt, })), }, twitter: { card: 'summary_large_image', title: product.name, description: product.description, images: [product.featuredImage], }, } }
Performance Monitoring
Set up monitoring to track the impact of your migration:
// app/layout.tsx import { Analytics } from '@vercel/analytics/react' export default function RootLayout({ children }) { return ( <html> <body> {children} <Analytics /> </body> </html> ) }
The migration from Pages Router to App Router represents a significant architectural shift that, while complex, provides substantial long-term benefits. The key to success lies in methodical planning, gradual implementation, and thorough testing at each stage. Start with simple pages, establish patterns for your team, and progressively tackle more complex scenarios.
Remember that both routers can coexist during migration, allowing you to move at a comfortable pace without disrupting production functionality. Focus on leveraging Server Components for improved performance, implement proper error boundaries for better user experience, and take advantage of the enhanced metadata API for SEO benefits.
If you're dealing with a complex Next.js application that includes payment integration, subscription management, or other critical business logic, consider getting expert assistance to ensure a smooth migration without disrupting your revenue streams. Our Next.js optimization service specializes in App Router migrations for production applications, helping teams navigate the complexities while maintaining system reliability and performance.
Related Articles


