MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

Next.js 13 App Router: The Complete Migration Guide

Migrate to App Router: layouts, loading UI, error handling, and server components.

Next.jsApp RouterMigrationFrontend

By MinhVo

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.

Migration Strategy

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

ConceptPages RouterApp Router
Data fetchinggetServerSideProps / getStaticPropsasync Server Components + fetch
Layouts_app.tsx (global only)Nested layout.tsx per route
Loading statesManual implementationloading.tsx + Suspense
Error handling_error.tsx / error.tsxerror.tsx per segment
Client componentsDefaultRequires 'use client' directive
Metadata<Head> componentmetadata export or <head>
RoutinguseRouter from next/routeruseRouter 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.

Data Fetching Architecture

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 equivalent

Migrating 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

  1. Migrate bottom-up: Start with leaf pages (blog posts, product pages) before migrating complex layouts and shared components.

  2. Run both routers simultaneously: The app/ and pages/ directories coexist. Use this to migrate one route at a time without big-bang risk.

  3. Create a shared components library: Extract reusable components into a components/ directory that both routers can import.

  4. Audit 'use client' boundaries: Use NEXT_DEBUG_BUILD=1 to see which components are client-rendered. Minimize the boundary to reduce client JavaScript.

  5. Test with React Strict Mode: Enable reactStrictMode: true to catch side effects that would cause issues in concurrent rendering.

  6. Monitor bundle size changes: Use @next/bundle-analyzer to track how each migration phase affects client bundle size.

  7. Update testing strategy: Server Components require different testing approaches. Use renderAsync for async components and mock fetch at the component level.

  8. 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

PitfallImpactSolution
Migrating all routes at onceHigh risk, hard to debug issuesMigrate one route per PR with automated tests
Using useRouter from wrong packageImport errors or missing functionalityUse next/navigation for App Router, next/router for Pages Router
Forgetting 'use client' on interactive componentsComponents silently fail to hydrateAdd 'use client' directive at the top of files using hooks or event handlers
Server Component importing client-only libraryBuild error—library uses Node.js or browser APIsWrap the import in a Client Component or use dynamic import
Passing non-serializable props across Server/Client boundaryRuntime error—functions, dates, Maps can't be serializedEnsure props are plain objects, arrays, strings, numbers, or booleans
Breaking SEO with client-side renderingPages rendered without metadataUse 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

FeatureNext.js App RouterRemixGatsbyAstro
Migration PathIncremental (coexists with Pages)Full rewritePlugin migrationFramework-agnostic
Server ComponentsNative React RSCSimilar (loader functions)GraphQLIsland Architecture
StreamingBuilt-inBuilt-inNot availablePartial
Learning CurveMedium-highMediumMediumLow
EcosystemVercel, large communityShopify, growingNetlify, decliningGrowing

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:

  1. Migrate incrementally—both routers coexist, so there's no big-bang requirement
  2. Start with read-heavy, low-interactivity routes where Server Components provide the most benefit
  3. Minimize 'use client' boundaries to keep client bundles small
  4. Replace getServerSideProps with component-level fetch calls in Server Components
  5. 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.