Next.js 15 App Router Migration: From Pages to App Directory in 2026
Migrate your Next.js app from Pages Router to App Router step by step — routing, layouts, data fetching, and deployment.
Next.js 15 App Router Migration: From Pages to App Directory in 2026
The App Router has matured significantly since its introduction. With Next.js 15, it's now the recommended approach for all new Next.js projects. If you're still running the Pages Router, 2026 is the year to migrate. Here's your comprehensive guide.
The hard part is not creating an app folder. The hard part is preserving production behavior while changing routing, data loading, metadata, layouts, error handling, and cache semantics. Treat the migration as a product change with measurable checkpoints, not a folder rename.
Why Migrate Now?
The App Router offers several advantages that are now production-battle-tested:
- React Server Components: Drastically reduce client-side JavaScript
- Streaming and Suspense: Progressive loading for better perceived performance
- Nested Layouts: Persistent UI across routes without re-rendering
- Parallel Routes: Multiple pages rendered simultaneously in the same layout
- Server Actions: Full-stack mutations without API routes
- Enhanced Caching: More granular control over data caching
Migration Strategy: Incremental Adoption
The key to a successful migration is doing it incrementally. Next.js supports both routers simultaneously, so you can migrate route by route.
Before touching routes, create a short inventory:
| Area | What to record | Why it matters |
|---|---|---|
| Routes | Static, dynamic, catch-all, API routes | Determines migration order |
| Data fetching | getStaticProps, getServerSideProps, client fetching | Maps to Server Components and fetch caching |
| SEO | titles, canonicals, Open Graph, structured data | Prevents search regressions |
| Auth | middleware, cookies, redirects | Prevents broken protected routes |
| Errors | 404, 500, loading states | Maps to not-found.tsx, error.tsx, loading.tsx |
| Performance | bundle size and Core Web Vitals | Confirms the migration improved something |
Step 1: Setup the App Directory
# Your existing structure
pages/
_app.tsx
_document.tsx
index.tsx
about.tsx
blog/
index.tsx
[slug].tsx
# Add the app directory alongside pages/
app/
layout.tsx
page.tsx
Step 2: Create the Root Layout
Replace _app.tsx and _document.tsx with a single layout.tsx:
// app/layout.tsx
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: {
default: "My App",
template: "%s | My App",
},
description: "My application description",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body className={inter.className}>{children}</body>
</html>
);
}
Step 3: Migrate Data Fetching
The biggest change is moving from getServerSideProps / getStaticProps to native async/await in Server Components:
// Before (Pages Router)
export async function getServerSideProps() {
const data = await fetch("https://api.example.com/posts");
const posts = await data.json();
return { props: { posts } };
}
export default function BlogPage({ posts }) {
return <PostList posts={posts} />;
}
// After (App Router)
export default async function BlogPage() {
const data = await fetch("https://api.example.com/posts");
const posts = await data.json();
return <PostList posts={posts} />;
}
Step 4: Convert Dynamic Routes
// Before: pages/blog/[slug].tsx
export async function getStaticPaths() {
const slugs = await getAllSlugs();
return {
paths: slugs.map((slug) => ({ params: { slug } })),
fallback: "blocking",
};
}
export async function getStaticProps({ params }) {
const post = await getPost(params.slug);
return { props: { post }, revalidate: 60 };
}
// After: app/blog/[slug]/page.tsx
export async function generateStaticParams() {
const slugs = await getAllSlugs();
return slugs.map((slug) => ({ slug }));
}
export default async function BlogPostPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = await getPost(slug);
return <PostContent post={post} />;
}
Step 5: Handle Client-Side Interactivity
In the App Router, components are Server Components by default. For interactivity, use the "use client" directive:
// components/LikeButton.tsx
"use client";
import { useState } from "react";
export function LikeButton({ initialCount }: { initialCount: number }) {
const [count, setCount] = useState(initialCount);
return (
<button onClick={() => setCount(count + 1)}>
❤️ {count}
</button>
);
}
Handling Metadata
Replace next/head with the metadata API:
// Before
import Head from "next/head";
export default function AboutPage() {
return (
<>
<Head>
<title>About Us</title>
<meta name="description" content="Learn about us" />
</Head>
<main>...</main>
</>
);
}
// After
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "About Us",
description: "Learn about us",
openGraph: {
title: "About Us",
description: "Learn about us",
},
};
export default function AboutPage() {
return <main>...</main>;
}
Loading and Error States
// app/blog/loading.tsx
export default function Loading() {
return (
<div className="animate-pulse space-y-4">
<div className="h-8 bg-gray-200 rounded w-3/4" />
<div className="h-4 bg-gray-200 rounded w-full" />
<div className="h-4 bg-gray-200 rounded w-5/6" />
</div>
);
}
// app/blog/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>
);
}
Middleware Updates
Middleware works the same way with the App Router, but you should update patterns:
// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function middleware(request: NextRequest) {
// Redirect old pages/ routes to new app/ routes if needed
if (request.nextUrl.pathname.startsWith("/old-path")) {
return NextResponse.redirect(
new URL("/new-path", request.url)
);
}
}
export const config = {
matcher: ["/old-path/:path*"],
};
Common Pitfalls to Avoid
- Don't overuse "use client": Keep the client boundary as low as possible in your component tree
- Don't fetch data in client components: Let Server Components handle data fetching
- Use Suspense boundaries wisely: They control streaming granularity
- Don't import server-only code in client components: Use the
server-onlypackage to catch this at build time - Handle searchParams correctly: They're now Promises in Next.js 15
SEO Migration Checklist
Search visibility can regress during App Router migrations if metadata and canonicals are treated as an afterthought. Check these items route by route:
- Preserve canonical URLs unless the URL intentionally changed.
- Move
next/headtags intometadataorgenerateMetadata. - Keep article
publishedTime,modifiedTime, author, and Open Graph image data. - Generate
sitemap.xmlfrom the same source of truth as your routes. - Redirect old URLs with permanent redirects instead of letting them 404.
- Confirm important routes return
200, not soft-404 content. - Test the HTML response without client JavaScript for primary content.
For a blog route, the App Router version should include a canonical and article metadata:
export async function generateMetadata({ params }): Promise<Metadata> {
const post = await getPost(params.slug);
return {
title: post.title,
description: post.description,
alternates: {
canonical: `https://example.com/blog/${post.slug}`,
},
openGraph: {
type: "article",
publishedTime: post.publishedAt,
modifiedTime: post.updatedAt ?? post.publishedAt,
authors: [post.author],
},
};
}
Rollout Plan I Use
For production products, I prefer this order:
- Migrate low-risk static pages first.
- Move shared layout and metadata patterns.
- Migrate blog or content routes after canonicals and sitemap generation are verified.
- Migrate authenticated dashboards after middleware and cookie behavior are tested.
- Migrate API routes only when there is a clear benefit or when Server Actions replace them.
After each group, compare:
- Build output and route type.
- HTML source for primary content.
- Core Web Vitals on important pages.
- Search Console inspection for indexable routes.
- Analytics for traffic or conversion drops.
This turns the migration into a controlled release instead of a risky rewrite.
Conclusion
Migrating from Pages Router to App Router is a significant but worthwhile effort. The incremental migration path means you don't need to do it all at once. Start with your highest-traffic pages, measure the performance improvements, and gradually migrate the rest. The benefits in bundle size, performance, and developer experience make it a compelling upgrade for 2026.