Next.js

Next.js 15 App Router Migration: From Pages to App Directory in 2026

A comprehensive step-by-step guide to migrating your Next.js application from the Pages Router to the App Router, covering routing, layouts, data fetching, and deployment strategies.

Sameer Sabir
Updated:
15 min read
Next.jsApp RouterMigrationReactTypeScriptSSR

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.

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.

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

  1. Don't overuse "use client": Keep the client boundary as low as possible in your component tree
  2. Don't fetch data in client components: Let Server Components handle data fetching
  3. Use Suspense boundaries wisely: They control streaming granularity
  4. Don't import server-only code in client components: Use the server-only package to catch this at build time
  5. Handle searchParams correctly: They're now Promises in Next.js 15

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.

Found this blog helpful? Have questions or suggestions?

Related Blogs