Migrating to Next.js 15 App Router
Next.js 13+ introduced the App Router as the future of Next.js. If you're on Pages Router, migration is inevitable. Here's how to do it smoothly.
Why Migrate?
Server components: Write components that run only on server. No client-side JavaScript overhead.
Streaming: Send HTML to browser while backend fetches data. Faster perceived performance.
Simpler routing: File-based routes, no config. /app/blog/page.tsx becomes /blog.
Layouts: Persistent layouts without state resets.
High-Level Differences
| Feature | Pages Router | App Router |
|---|---|---|
| Route definition | /pages/blog/post.tsx | /app/blog/[slug]/page.tsx |
| Layouts | Per-page or hoc | /app/layout.tsx (hierarchical) |
| Data fetching | getServerSideProps, getStaticProps | fetch() in components |
| Client-side state | Local hooks | use() hook (with Suspense) |
| Middleware | /pages/_middleware.ts | /middleware.ts (root) |
Step 1: Set Up App Router
Create /app directory alongside /pages. Both coexist during migration:
src/
app/ # New app router
pages/ # Old pages router (temporary)
Next.js 13+ prioritizes app-router. Gradually move routes from pages to app.
Step 2: Create Root Layout
Replace /pages/_app.tsx and /pages/_document.tsx with /app/layout.tsx:
// app/layout.tsx
export const metadata = {
title: 'My App',
description: 'Generated by create next app',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
)
}
Step 3: Move Pages
For each page in /pages, create a corresponding file in /app:
pages/blog/index.tsx → app/blog/page.tsx
pages/blog/[slug].tsx → app/blog/[slug]/page.tsx
pages/admin/[...slug].tsx → app/admin/[[...slug]]/page.tsx
Step 4: Server Components by Default
App Router pages are server components by default:
// app/blog/page.tsx - Server component
export default async function BlogPage() {
const posts = await fetch('https://api.example.com/posts')
.then(res => res.json())
return (
<div>
{posts.map(post => (
<article key={post.id}>{post.title}</article>
))}
</div>
)
}
No getStaticProps or getServerSideProps needed. Simpler, clearer.
Step 5: Client Interactions
For interactivity (state, effects, event handlers), mark components with 'use client':
// app/components/counter.tsx
'use client'
import { useState } from 'react'
export default function Counter() {
const [count, setCount] = useState(0)
return (
<button onClick={() => setCount(count + 1)}>
Count: {count}
</button>
)
}
Keep client components small. Parent component can remain server component, embedding client components where needed.
Step 6: Handle Data Fetching
No more getStaticProps. Fetch in server components:
// app/blog/page.tsx
export const revalidate = 60 // ISR: revalidate every 60 seconds
export default async function BlogPage() {
const posts = await fetch('https://api.example.com/posts', {
next: { revalidate: 60 } // or use revalidate export
}).then(res => res.json())
return (
<ul>
{posts.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)
}
Step 7: Implement Streaming
Wrap slow-loading sections in <Suspense>:
import { Suspense } from 'react'
async function Posts() {
const posts = await fetch('https://api.example.com/posts')
.then(res => res.json())
return posts.map(post => <article key={post.id}>{post.title}</article>)
}
export default function BlogPage() {
return (
<>
<h1>Blog</h1>
<Suspense fallback={<p>Loading posts...</p>}>
<Posts />
</Suspense>
</>
)
}
Browser gets HTML immediately with loading UI. When data fetches, it streams HTML without re-rendering the page.
Step 8: Server Actions (Optional)
Server actions let you call server functions from client components without API routes:
// app/actions.ts
'use server'
export async function submitForm(formData: FormData) {
const name = formData.get('name')
// Process on server
return { success: true, name }
}
// app/form.tsx
'use client'
import { submitForm } from '@/app/actions'
export default function Form() {
return (
<form action={submitForm}>
<input name="name" />
<button type="submit">Submit</button>
</form>
)
}
Common Pitfalls
- Forgetting 'use client': Client interactions fail silently if you forget the directive.
- Props from server to client: Server component data must be JSON-serializable; functions can't pass to clients.
- Middleware changes: Old-style middleware won't work. Rewrite in
/middleware.ts. - Dynamic imports: Use
dynamic()from next/dynamic for large client components.
Migration Order
- Migrate static pages first (no data fetching).
- Then ISR/SSG pages.
- Finally SSR pages (most complex).
- Keep Pages Router for complex API routes until you're comfortable with App Router.
Performance Gains
Typical results after migration:
- Time to first byte: 20-30% faster (server rendering closer to data).
- JavaScript sent to browser: 30-50% less (server components don't ship code).
- Perceived load time: 15-25% faster (streaming).
These gains compound. A successful migration is worth the effort.
Frequently asked questions
Can I use Pages Router and App Router together?
Yes. Both coexist during migration. App Router takes priority. Gradually move routes from pages/ to app/.
Do I still need getServerSideProps in App Router?
No. Fetch data directly in server components. App Router simplifies data fetching significantly.
What happens if I forget 'use client' on a component with state?
The component will error at runtime. useState, useEffect, and other hooks require the 'use client' directive.