Skip to content
Sameer Sabir
Next.js

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.

Sameer SabirUpdated 17 min read
  • Next.js
  • App Router
  • Migration
  • React
  • TypeScript
  • SSR

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:

AreaWhat to recordWhy it matters
RoutesStatic, dynamic, catch-all, API routesDetermines migration order
Data fetchinggetStaticProps, getServerSideProps, client fetchingMaps to Server Components and fetch caching
SEOtitles, canonicals, Open Graph, structured dataPrevents search regressions
Authmiddleware, cookies, redirectsPrevents broken protected routes
Errors404, 500, loading statesMaps to not-found.tsx, error.tsx, loading.tsx
Performancebundle size and Core Web VitalsConfirms 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

  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

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/head tags into metadata or generateMetadata.
  • Keep article publishedTime, modifiedTime, author, and Open Graph image data.
  • Generate sitemap.xml from 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:

  1. Migrate low-risk static pages first.
  2. Move shared layout and metadata patterns.
  3. Migrate blog or content routes after canonicals and sitemap generation are verified.
  4. Migrate authenticated dashboards after middleware and cookie behavior are tested.
  5. 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.

App Router migrations go badly when they are done all at once. If you want yours planned in shippable increments, that is React and Next.js web application development — tell me about your codebase.

  1. Next.js 15 Performance Optimization: Advanced Techniques for Lightning-Fast Web AppsNext.js · 9 min
  2. Building a Website Audit Platform with Next.js, Playwright, Lighthouse, and axe-coreNext.js · 10 min
  3. Streaming AI Responses in Next.js: Building Real-Time User Experiences with LLMsNext.js · 12 min

Next step

Have a project that needs this kind of work?

I build React and Next.js products end to end, including the performance and AI work these posts cover. Tell me where yours is stuck.