Back to Blog
August 19, 20269 min read2 views

Next.js App Router vs Pages Router: When and Why to Migrate

Next.jsTypeScriptWeb Development
JL
Written by Julius Legaspi
LinkedInGitHub
Share
# Next.js App Router vs Pages Router: When and Why to Migrate ## Introduction If you're working with Next.js, you've probably heard the debate: Pages Router vs App Router. One isn't universally better than the other — the right choice depends on your project's needs, your team's experience, and what you're building. After migrating several production applications from Pages Router to App Router, I've developed a practical framework for deciding when to migrate and how to do it successfully. ## Understanding the Core Differences Before diving into migration decisions, let's clarify what each router actually does differently. ### Pages Router (Legacy) The Pages Router uses a file-based routing system where each file in the `pages` directory becomes a route. It's been the standard since Next.js 9 and powers millions of production applications. **Key characteristics:** - `getStaticProps` and `getServerSideProps` for data fetching - API routes in `pages/api` - Client-side rendering by default - `_app.tsx` and `_document.tsx` for global customization ### App Router (Modern) The App Router, introduced in Next.js 13, brings React Server Components, nested layouts, and a new data fetching model. It's the future of Next.js development. **Key characteristics:** - React Server Components by default - `layout.tsx` for shared layouts - Server Actions for mutations - `loading.tsx` and `error.tsx` for UI states - Streaming and Suspense support ## When to Stay with Pages Router Not every project needs to migrate. Here's when I recommend sticking with Pages Router: ### 1. Your Application is Stable and Working If your Pages Router application is performing well, meeting business requirements, and your team is productive with it, migration may not be necessary. The old saying applies: "If it ain't broke, don't fix it." ### 2. You're Using Pages Router-Specific Patterns Heavily Some patterns work differently in App Router: - Custom `_app.tsx` with complex logic - Custom `_document.tsx` with specific optimizations - Heavy use of `getStaticProps` with `revalidate` - Complex API routes with middleware If you've built sophisticated patterns around these features, migration requires careful planning. ### 3. Your Team Isn't Ready App Router requires understanding React Server Components, a fundamentally different mental model. If your team is under pressure to deliver features, adding migration complexity may not be wise. ### 4. You're Using Libraries That Don't Support App Router Some popular libraries haven't fully embraced App Router yet. Check your dependencies before migrating. ## When to Migrate to App Router Here's when migration makes sense: ### 1. You Need Better Performance App Router's Server Components and streaming capabilities can significantly improve performance: - **Reduced JavaScript bundle size** — Server Components don't ship JavaScript to the client - **Faster initial page loads** — Streaming allows progressive rendering - **Better Core Web Vitals** — Improved LCP, FID, and CLS scores ### 2. You're Building New Features That Benefit from Server Components If you're adding features that would benefit from server-side rendering (dashboards, content-heavy pages, e-commerce product pages), App Router provides a cleaner architecture. ### 3. You Want Nested Layouts App Router's nested layout system is more powerful than Pages Router's `_app.tsx`: ```tsx // app/dashboard/layout.tsx export default function DashboardLayout({ children }) { return (
{children}
) } ``` This pattern is cleaner and more maintainable than wrapping everything in `_app.tsx`. ### 4. You Need Server Actions Server Actions simplify form handling and mutations: ```tsx // app/actions.ts 'use server' export async function createPost(formData: FormData) { const title = formData.get('title') // Save to database revalidatePath('/posts') } ``` This eliminates boilerplate API routes for simple operations. ## Migration Strategy: The Incremental Approach I don't recommend big-bang migrations. Instead, use the incremental approach: ### Phase 1: Preparation 1. **Audit your current application** - List all pages and their data fetching patterns - Identify shared components and layouts - Check library compatibility 2. **Set up the App Router alongside Pages Router** Next.js supports both routers simultaneously. You can have: ``` /pages (existing routes) /app (new routes) ``` 3. **Create a migration checklist** For each page, document: - Current data fetching method - Dependencies on `_app.tsx` or `_document.tsx` - Client-side state management - API routes used ### Phase 2: Start with Simple Pages Begin with pages that have minimal complexity: 1. **Static pages** (about, contact, terms) 2. **Pages with simple data fetching** 3. **Pages without complex client-side logic** For each page: 1. Create the new route in `app/` 2. Migrate the data fetching to Server Components 3. Test thoroughly 4. Update internal links ### Phase 3: Migrate Complex Pages Once you're comfortable with simple migrations, tackle complex pages: 1. **Pages with authentication** 2. **Pages with real-time updates** 3. **Pages with complex forms** 4. **Pages with heavy client-side interactivity** ### Phase 4: Migrate API Routes Migrate API routes to Server Actions where appropriate: - **Simple CRUD operations** → Server Actions - **Webhooks and external integrations** → Route Handlers - **Complex APIs** → Consider keeping as Route Handlers ## Real-World Migration Example Let me walk through migrating a typical Next.js page. ### Before (Pages Router) ```tsx // pages/dashboard.tsx import { GetServerSideProps } from 'next' import { useState, useEffect } from 'react' interface DashboardProps { user: User stats: Stats } export const getServerSideProps: GetServerSideProps = async (context) => { const session = await getSession(context) if (!session) return { redirect: { destination: '/login', permanent: false } } const user = await getUser(session.userId) const stats = await getStats(session.userId) return { props: { user, stats } } } export default function Dashboard({ user, stats }: DashboardProps) { const [realTimeStats, setRealTimeStats] = useState(stats) useEffect(() => { const interval = setInterval(async () => { const updated = await fetch('/api/stats').then(r => r.json()) setRealTimeStats(updated) }, 5000) return () => clearInterval(interval) }, []) return (

Welcome, {user.name}

) } ``` ### After (App Router) ```tsx // app/dashboard/page.tsx import { redirect } from 'next/navigation' import { Suspense } from 'react' import { getUser, getStats } from '@/lib/data' import { StatsWidget } from './stats-widget' export default async function DashboardPage() { const session = await getSession() if (!session) redirect('/login') const [user, stats] = await Promise.all([ getUser(session.userId), getStats(session.userId) ]) return (

Welcome, {user.name}

}>
) } ``` ```tsx // app/dashboard/stats-widget.tsx 'use client' import { useState, useEffect } from 'react' interface StatsWidgetProps { userId: string initialStats: Stats } export function StatsWidget({ userId, initialStats }: StatsWidgetProps) { const [stats, setStats] = useState(initialStats) useEffect(() => { const interval = setInterval(async () => { const updated = await fetch(`/api/stats?userId=${userId}`).then(r => r.json()) setStats(updated) }, 5000) return () => clearInterval(interval) }, [userId]) return (
{/* Stats display */}
) } ``` **Key improvements:** - Data fetching moved to the page level (Server Component) - Client component isolated for real-time updates - Better separation of concerns - Cleaner async/await patterns ## Common Migration Pitfalls ### 1. Mixing Server and Client Components Incorrectly **Mistake:** Making everything a Client Component. **Solution:** Start with Server Components by default. Only add `'use client'` when you need: - `useState`, `useEffect`, or other React hooks - Event handlers - Browser APIs - Third-party libraries that require client-side rendering ### 2. Forgetting About Hydration **Mistake:** Using server-only code in Client Components. **Solution:** Remember the boundary: - Server Components can access databases, file system, etc. - Client Components can only access what's available in the browser ### 3. Not Updating Data Fetching Patterns **Mistake:** Still using `getServerSideProps` in App Router. **Solution:** Use the new data fetching patterns: ```tsx // Server Component async function getData() { const res = await fetch('https://api.example.com/data', { next: { revalidate: 3600 } // Cache for 1 hour }) return res.json() } ``` ### 4. Ignoring Layout Nesting **Mistake:** Creating separate layouts for every page. **Solution:** Use nested layouts strategically: - Root layout for global elements (navbar, footer) - Section layouts for shared UI (dashboard sidebar) - Page layouts only when absolutely necessary ## Performance Considerations ### Bundle Size App Router can significantly reduce bundle size: ```tsx // Server Component - no JavaScript sent to client async function ProductList() { const products = await db.product.findMany() return (
    {products.map(product => (
  • {product.name}
  • ))}
) } ``` ### Streaming Use streaming to improve perceived performance: ```tsx // app/dashboard/page.tsx export default function Dashboard() { return (

Dashboard

}> }>
) } ``` ### Caching Understand Next.js caching layers: 1. **Request Memoization** — Deduplicates requests in a single render 2. **Data Cache** — Persists fetched data across requests 3. **Full Route Cache** — Caches the rendered result of a route 4. **Router Cache** — Client-side cache for pre-fetched routes ## Decision Framework Use this checklist to decide whether to migrate: **Stay with Pages Router if:** - [ ] Application is stable and meeting requirements - [ ] Team is productive and not asking for App Router features - [ ] Heavy use of Pages Router-specific patterns - [ ] Dependencies don't fully support App Router - [ ] No clear performance issues **Migrate to App Router if:** - [ ] Need better performance (Core Web Vitals) - [ ] Building new features that benefit from Server Components - [ ] Want nested layouts - [ ] Need Server Actions for form handling - [ ] Team is ready to learn the new patterns - [ ] Dependencies support App Router ## Conclusion The Pages Router vs App Router decision isn't about which is "better" — it's about which is right for your specific situation. Both are production-ready and will continue to be supported. If you're starting a new project, App Router is the way to go. If you have an existing Pages Router application that's working well, there's no rush to migrate. If you need better performance or are building features that benefit from Server Components, migration is worth the investment. The key is to migrate incrementally, test thoroughly, and focus on the benefits that matter most to your users and your team. --- **Need help with your Next.js migration?** [Contact me](/contact) to discuss your project and how I can help.

Comments (0)

Loading comments...