Skip to content
Framework

Next.js 16 App Router: Complete Migration Guide from Pages Router

January 4, 2025
18 min
Teknopulse
Next.js 16 App Router: Complete Migration Guide from Pages Router

Next.js 16 App Router: Complete Migration Guide from Pages Router

Next.js 16 represents a significant evolution in React framework architecture, with the App Router at its core. If you are still using the Pages Router, this comprehensive migration guide will help you transition smoothly while understanding the fundamental architectural changes.

What is the App Router?

The App Router is a new routing system introduced in Next.js 13 and enhanced in subsequent versions. It leverages React Server Components by default, providing better performance, streaming capabilities, and a more intuitive file-based routing structure.

Unlike the Pages Router, which runs entirely on the client side with optional getServerSideProps, the App Router uses Server Components as the default. This fundamental shift changes how you think about data fetching, state management, and component architecture.

Key Differences: Pages Router vs App Router

Before diving into migration, let us understand the core architectural differences:

Rendering Model

**Pages Router:** Client-side rendering with optional server-side data fetching via getServerSideProps or getStaticProps.

**App Router:** Server-side rendering by default using React Server Components, with selective client-side rendering via the "use client" directive.

File Structure

**Pages Router:** Routes defined in the `pages/` directory with special files like `_app.js` and `_document.js`.

**App Router:** Routes defined in the `app/` directory with collocated layouts, templates, and loading states.

Data Fetching

**Pages Router:** Dedicated data fetching functions that run on the server before rendering.

**App Router:** Direct async/await in Server Components, eliminating the need for special data fetching functions.

Migration Strategy: Incremental vs Big Bang

You have two approaches when migrating to the App Router:

1. Incremental Migration (Recommended)

Next.js supports running both routers simultaneously. You can gradually migrate routes from `pages/` to `app/` while keeping your existing application functional.

**Benefits:**

  • Lower risk
  • Learn as you migrate
  • Easier to test and debug
  • No application downtime

**Drawback:** Temporary code duplication during transition

2. Big Bang Migration

Migrate the entire application at once.

**Benefits:**

  • Clean slate, no legacy code
  • Consistent architecture

**Drawbacks:**

  • Higher risk
  • More testing required
  • Potential for extended downtime

**Recommendation:** Start with incremental migration for production applications. Begin with low-risk pages like marketing sites, then gradually move to complex features.

Step-by-Step Migration Process

Step 1: Preparation

Before migrating, ensure your Next.js version is up to date:

# Check your current version
npm list next

# Upgrade to Next.js 16
npm install next@16 react@19 react-dom@19

# Or using pnpm
pnpm add next@16 react@19 react-dom@19

Update your `next.config.js`:

// next.config.js - Before
module.exports = {
  reactStrictMode: true,
}

// next.config.js - After (for Next.js 16)
const nextConfig = {
  reactStrictMode: true,
  // App Router is enabled by default when app/ directory exists
}

module.exports = nextConfig

Step 2: Create the App Directory Structure

Create the new `app/` directory alongside your existing `pages/` directory:

your-project/
β”œβ”€β”€ app/                    # New App Router directory
β”‚   β”œβ”€β”€ layout.tsx          # Root layout
β”‚   β”œβ”€β”€ page.tsx            # Home page
β”‚   β”œβ”€β”€ globals.css         # Global styles
β”‚   └── (routes)/          # Route groups
β”œβ”€β”€ pages/                  # Existing Pages Router (keep for now)
β”‚   β”œβ”€β”€ _app.tsx
β”‚   β”œβ”€β”€ _document.tsx
β”‚   └── index.tsx
└── public/

Step 3: Create the Root Layout

The root layout replaces `_app.tsx` and `_document.tsx`:

// pages/_app.tsx - Before
import type { AppProps } from 'next/app'
import '../styles/globals.css'

export default function App({ Component, pageProps }: AppProps) {
  return <Component {...pageProps} />
}

// pages/_document.tsx - Before
import { Html, Head, Main, NextScript } from 'next/document'

export default function Document() {
  return (
    <Html>
      <Head />
      <body>
        <Main />
        <NextScript />
      </body>
    </Html>
  )
}
// app/layout.tsx - After
import type { Metadata } from 'next'
import './globals.css'

export const metadata: 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>
  )
}

Step 4: Migrate Pages to Server Components

Convert your existing pages to Server Components:

// pages/dashboard.tsx - Before
import { useState, useEffect } from 'react'
import { GetServerSideProps } from 'next'

export default function Dashboard({ user }) {
  const [data, setData] = useState(null)

  useEffect(() => {
    fetchData().then(setData)
  }, [])

  return <div>{/* JSX */}</div>
}

export const getServerSideProps: GetServerSideProps = async (context) => {
  const user = await getUser(context.params.id)
  return { props: { user } }
}
// app/dashboard/page.tsx - After
// No "use client" needed - this is a Server Component by default

async function getUser(id: string) {
  const res = await fetch(`https://api.example.com/users/${id}`)
  if (!res.ok) throw new Error('Failed to fetch user')
  return res.json()
}

export default async function Dashboard({
  params,
}: {
  params: { id: string }
}) {
  // Direct data fetching - no special functions needed
  const user = await getUser(params.id)

  return <div>{/* JSX */}</div>
}

File Structure Changes Deep Dive

Dynamic Routes

**Pages Router:**

// pages/posts/[id].tsx
export default function Post({ post }) {
  return <article>{post.title}</article>
}

export async function getStaticPaths() {
  const posts = await getPosts()
  return {
    paths: posts.map((post) => ({ params: { id: post.id } })),
    fallback: 'blocking',
  }
}

export async function getStaticProps({ params }) {
  const post = await getPost(params.id)
  return { props: { post } }
}

**App Router:**

// app/posts/[id]/page.tsx
export default async function Post({
  params,
}: {
  params: { id: string }
}) {
  const post = await getPost(params.id)
  return <article>{post.title}</article>
}

// Generate static params (equivalent to getStaticPaths)
export async function generateStaticParams() {
  const posts = await getPosts()
  return posts.map((post) => ({ id: post.id }))
}

Catch-All Routes

**Pages Router:**

// pages/docs/[...slug].tsx
export default function DocsPage() {
  return <div>Documentation</div>
}

**App Router:**

// app/docs/[...slug]/page.tsx
export default function DocsPage({
  params,
}: {
  params: { slug: string[] }
}) {
  return <div>Documentation</div>
}

Optional Catch-All Routes

**Pages Router:**

// pages/[[...slug]].tsx

**App Router:**

// app/[[...slug]]/page.tsx

Data Fetching Patterns

Server Components Data Fetching

The App Router simplifies data fetching with async components:

// app/products/page.tsx
async function getProducts() {
  const res = await fetch('https://api.example.com/products', {
    // Next.js extends fetch with caching options
    next: { revalidate: 3600 }, // Revalidate every hour
  })
  if (!res.ok) throw new Error('Failed to fetch products')
  return res.json()
}

export default async function ProductsPage() {
  const products = await getProducts()

  return (
    <div>
      <h1>Products</h1>
      <ul>
        {products.map((product) => (
          <li key={product.id}>{product.name}</li>
        ))}
      </ul>
    </div>
  )
}

Static Data Fetching

For static content that rarely changes:

const res = await fetch('https://api.example.com/data', {
  cache: 'force-cache', // Equivalent to getStaticProps with revalidate: false
})

Dynamic Data Fetching

For real-time data:

const res = await fetch('https://api.example.com/data', {
  cache: 'no-store', // Equivalent to getServerSideProps
})

Parallel Data Fetching

One of the powerful features of Server Components is parallel data fetching:

async function getUser(id: string) {
  const res = await fetch(`https://api.example.com/users/${id}`)
  return res.json()
}

async function getUserPosts(id: string) {
  const res = await fetch(`https://api.example.com/users/${id}/posts`)
  return res.json()
}

export default async function UserProfile({
  params,
}: {
  params: { id: string }
}) {
  // These fetches run in parallel
  const [user, posts] = await Promise.all([
    getUser(params.id),
    getUserPosts(params.id),
  ])

  return (
    <div>
      <h1>{user.name}</h1>
      <ul>
        {posts.map((post) => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </div>
  )
}

Layouts and Templates

Nested Layouts

The App Router supports nested layouts that preserve state and avoid unnecessary re-renders:

// app/layout.tsx - Root layout
export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html>
      <body>
        <Header />
        {children}
        <Footer />
      </body>
    </html>
  )
}

// app/dashboard/layout.tsx - Dashboard layout
export default function DashboardLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <div className="dashboard">
      <Sidebar />
      <main>{children}</main>
    </div>
  )
}

// app/dashboard/settings/page.tsx
export default function SettingsPage() {
  return <div>Settings</div>
}

Route Groups

Use parentheses to organize routes without affecting the URL:

app/
β”œβ”€β”€ (marketing)/
β”‚   β”œβ”€β”€ about/
β”‚   β”‚   └── page.tsx       # /about
β”‚   └── contact/
β”‚       └── page.tsx       # /contact
β”œβ”€β”€ (auth)/
β”‚   β”œβ”€β”€ login/
β”‚   β”‚   └── page.tsx       # /login
β”‚   └── register/
β”‚       └── page.tsx       # /register
└── layout.tsx

Templates vs Layouts

Templates are similar to layouts but remount on navigation:

// app/template.tsx - Remounts on every navigation
export default function Template({
  children,
}: {
  children: React.ReactNode
}) {
  return <div className="animate-fade-in">{children}</div>
}

Use templates when you need:

Client Components Migration

  • Entry animations on every page visit
  • Fresh component state on navigation
  • Effects to re-run on navigation

Not all components can be Server Components. Mark interactive components with "use client":

// components/SearchBar.tsx
'use client'

import { useState } from 'react'

export function SearchBar() {
  const [query, setQuery] = useState('')

  return (
    <input
      type="text"
      value={query}
      onChange={(e) => setQuery(e.target.value)}
      placeholder="Search..."
    />
  )
}

The "use client" Directive

Add "use client" at the top of files that use:

Leaf Client Components Pattern

  • React hooks (useState, useEffect, useContext)
  • Event handlers (onClick, onChange)
  • Browser APIs (localStorage, window)
  • Third-party libraries that require client-side rendering

Keep Client Components at the leaves of your component tree:

// app/users/page.tsx - Server Component
import { UserCard } from '@/components/UserCard'

export default async function UsersPage() {
  const users = await getUsers()

  return (
    <div>
      {users.map((user) => (
        <UserCard key={user.id} user={user} />
        // UserCard can be a Client Component
        // but receives data from Server Component
      ))}
    </div>
  )
}

// components/UserCard.tsx - Client Component
'use client'

import { useState } from 'react'

export function UserCard({ user }: { user: User }) {
  const [isFollowing, setIsFollowing] = useState(false)

  return (
    <div>
      <h3>{user.name}</h3>
      <button onClick={() => setIsFollowing(!isFollowing)}>
        {isFollowing ? 'Unfollow' : 'Follow'}
      </button>
    </div>
  )
}

Loading and Error States

Loading UI

Create a loading.tsx file for automatic loading states:

// app/dashboard/loading.tsx
export default function Loading() {
  return (
    <div className="animate-pulse">
      <div className="h-4 bg-gray-200 w-3/4 mb-4"></div>
      <div className="h-4 bg-gray-200 w-1/2 mb-4"></div>
      <div className="h-4 bg-gray-200 w-5/6"></div>
    </div>
  )
}

Error Handling

Create an error.tsx file for error boundaries:

// app/dashboard/error.tsx
'use client'

export default function Error({
  error,
  reset,
}: {
  error: Error
  reset: () => void
}) {
  return (
    <div>
      <h2>Something went wrong!</h2>
      <button onClick={() => reset()}>Try again</button>
    </div>
  )
}

Not Found Handling

Create a not-found.tsx file for 404 pages:

// app/not-found.tsx
export default function NotFound() {
  return (
    <div>
      <h2>Page not found</h2>
      <a href="/">Go home</a>
    </div>
  )
}

Migration Challenges and Solutions

Challenge 1: No getInitialProps

**Problem:** getInitialProps is not supported in the App Router.

**Solution:** Use async Server Components with direct data fetching:

// Before
export default function Page({ data }) {
  return <div>{data}</div>
}

Page.getInitialProps = async () => {
  const res = await fetch('https://api.example.com/data')
  const data = await res.json()
  return { data }
}

// After
export default async function Page() {
  const res = await fetch('https://api.example.com/data')
  const data = await res.json()
  return <div>{data}</div>
}

Challenge 2: Context Providers

**Problem:** React Context cannot be used in Server Components.

**Solution:** Create a Client Component wrapper for providers:

// app/providers.tsx
'use client'

import { SessionProvider } from 'next-auth/react'

export function Providers({ children }: { children: React.ReactNode }) {
  return <SessionProvider>{children}</SessionProvider>
}

// app/layout.tsx
import { Providers } from './providers'

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html>
      <body>
        <Providers>{children}</Providers>
      </body>
    </html>
  )
}

Challenge 3: Third-Party Libraries

**Problem:** Many UI libraries require client-side rendering.

**Solution:** Wrap them in Client Components:

// components/ChartWrapper.tsx
'use client'

import { Line } from 'react-chartjs-2'

export function ChartWrapper({ data }: { data: any }) {
  return <Line data={data} />
}

// app/analytics/page.tsx
import { ChartWrapper } from '@/components/ChartWrapper'

export default async function AnalyticsPage() {
  const data = await getAnalyticsData()

  return (
    <div>
      <ChartWrapper data={data} />
    </div>
  )
}

Challenge 4: Middleware Configuration

**Problem:** Middleware behavior changes in App Router.

**Solution:** Update middleware for app directory routes:

// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'

export function middleware(request: NextRequest) {
  // Check authentication
  const token = request.cookies.get('token')

  if (!token && request.nextUrl.pathname.startsWith('/dashboard')) {
    return NextResponse.redirect(new URL('/login', request.url))
  }

  return NextResponse.next()
}

export const config = {
  matcher: ['/dashboard/:path*', '/admin/:path*'],
}

Common Pitfalls to Avoid

1. Overusing Client Components

Avoid marking everything as "use client". Default to Server Components and only use Client Components when necessary for interactivity.

2. Passing Server-Only Data to Client Components

Be careful not to pass functions, dates, or complex server objects to Client Components:

// Bad
export default function Page() {
  const data = { date: new Date(), fn: () => {} }
  return <ClientComponent data={data} />
}

// Good
export default function Page() {
  const data = { timestamp: Date.now() }
  return <ClientComponent data={data} />
}

3. Forgetting Caching Strategy

Always specify your caching strategy for fetch requests:

// Explicit is better than implicit
const res = await fetch(url, {
  next: { revalidate: 60 }, // or cache: 'force-cache' or cache: 'no-store'
})

4. Ignoring Error Boundaries

Implement error.tsx at appropriate levels to prevent cascading failures.

5. Not Using Parallel Data Fetching

Avoid sequential data fetching when data is independent:

// Bad - Sequential
const user = await getUser(id)
const posts = await getUserPosts(id)

// Good - Parallel
const [user, posts] = await Promise.all([
  getUser(id),
  getUserPosts(id),
])

Migration Checklist

Use this checklist to track your migration progress:

Pre-Migration

Core Migration

Route Migration

Component Migration

Features Migration

Post-Migration

Key Takeaways

  • [ ] Update to Next.js 16 and React 19
  • [ ] Backup your codebase or create a migration branch
  • [ ] Review and document all custom server configurations
  • [ ] Identify all pages using getServerSideProps, getStaticProps, and getInitialProps
  • [ ] Catalog all third-party libraries and their client-side requirements
  • [ ] Create `app/` directory structure
  • [ ] Create root layout (app/layout.tsx)
  • [ ] Migrate global styles
  • [ ] Update metadata configuration
  • [ ] Create home page (app/page.tsx)
  • [ ] Migrate static routes one by one
  • [ ] Migrate dynamic routes ([slug]/page.tsx)
  • [ ] Migrate catch-all routes ([...slug]/page.tsx)
  • [ ] Migrate API routes to route handlers
  • [ ] Update all internal links
  • [ ] Identify and mark Client Components with "use client"
  • [ ] Refactor data fetching to use async/await
  • [ ] Update Context Provider wrappers
  • [ ] Migrate form components
  • [ ] Update event handlers
  • [ ] Implement loading states (loading.tsx)
  • [ ] Implement error boundaries (error.tsx)
  • [ ] Configure redirects (middleware.ts)
  • [ ] Update authentication logic
  • [ ] Migrate image optimization
  • [ ] Test all routes manually
  • [ ] Run automated tests
  • [ ] Check console for warnings
  • [ ] Verify SEO metadata
  • [ ] Test authentication flows
  • [ ] Monitor performance metrics
  • [ ] Remove old `pages/` directory
  • [ ] Update documentation

Migrating to Next.js 16 App Router represents a significant architectural shift, but the benefits are substantial:

1. **Simplified Data Fetching**: No more special data fetching functionsβ€”just async/await in components.

2. **Better Performance**: Server Components reduce JavaScript sent to the client by default.

3. **Improved Developer Experience**: Collocated layouts, loading states, and error boundaries.

4. **Streaming Support**: Progressive page rendering for faster perceived load times.

5. **Future-Proof**: The App Router is the future of Next.js with continued investment and innovation.

Start with an incremental migration approach, focus on learning the new patterns, and gradually refactor your application. The migration effort pays off in improved performance, simpler code, and a better foundation for future development.

For more on React Server Components, check out our guide on [Understanding React Server Components in Next.js](/blog/next-js-server-components). To learn more about optimizing your application performance, read our [Web Performance Optimization guide](/blog/performance-optimization-strategies).

Frequently Asked Questions

Is Next.js 16 App Router backward compatible with Pages Router?

Yes, Next.js supports both routers simultaneously. You can run the `app/` and `pages/` directories together, allowing for incremental migration without breaking your existing application.

Do I need to rewrite my entire application to use App Router?

No, you can migrate gradually. Start with new features or low-risk pages like marketing content. Keep complex, interactive pages in Pages Router until you are comfortable with the new patterns.

What happens to my existing API routes in the pages/api directory?

You can keep using `pages/api/` routes alongside the App Router. For new API routes, use Route Handlers in the `app/` directory (e.g., `app/api/hello/route.ts`).

Can I use React Server Components with third-party UI libraries?

Most popular UI libraries like Material-UI, Chakra UI, and Tailwind components work with Server Components. However, components that rely on hooks or browser APIs need to be wrapped in Client Components.

How do I handle client-side state management with Server Components?

Use Client Components for state management at the leaves of your component tree. Pass data from Server Components to Client Components via props. Consider using Server Actions for mutations instead of complex client-side state.

What is the performance impact of migrating to App Router?

The App Router generally improves performance through reduced JavaScript bundle sizes, automatic code splitting, and streaming server rendering. However, the actual impact depends on your implementation and usage patterns.

How do I handle authentication in App Router?

Authentication in App Router typically involves middleware for route protection, Server Components for server-side session checks, and Client Components for login forms. Libraries like NextAuth.js have specific App Router integration patterns.

benihkode.web.id

Kebun digital untuk menanam ide, merawat eksperimen, dan memanen produk melalui kode.

Connect with Farmer

Subscribe to growing season updates

Β© 2025 benihkode.web.id. Built with love in the digital garden