Skip to content
Elmer Augusto Jacobo Otiniano, Product engineer · Full stack
Back to blog
Next.jsReactApp RouterTypeScript6 min read

Differences Between the App Router and Pages Router in Next.js

A complete guide to the differences between the App Router (app/) and Pages Router (pages/) in Next.js. Which one to use, when to migrate, and practical examples.

Next.js 13 introduced the App Router (app/ directory), a fundamental change in how we structure Next.js applications. But the Pages Router (pages/ directory) remains completely valid and supported. Which one should you use? Is it worth migrating? I'll explain it all.

Quick Comparison Table

FeaturePages Router (pages/)App Router (app/)
IntroducedNext.js 9 (2019)Next.js 13 (2022)
StatusStable, maintainedStable since Next.js 14
ComponentsClient Components onlyServer + Client Components
Layouts_app.js + _document.jsNative nested layouts
Data FetchinggetServerSideProps, getStaticPropsasync components, fetch
Loading StatesManualAutomatic loading.js
Error HandlingGlobal _error.jsPer-route error.js
StreamingNoYes (RSC + Suspense)

Pages Router: The Classic

File structure

pages/
├── _app.js          # Global layout
├── _document.js     # HTML document
├── index.js         # → /
├── about.js         # → /about
├── blog/
│   ├── index.js     # → /blog
│   └── [slug].js    # → /blog/post-1
└── api/
    └── hello.js     # → /api/hello

Example: Page with Data Fetching

// pages/blog/[slug].js
export default function BlogPost({ post }) {
  return (
    <div>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
    </div>
  );
}

// Server-side rendering
export async function getServerSideProps({ params }) {
  const post = await fetch(`https://api.com/posts/${params.slug}`).then((r) =>
    r.json()
  );
  return { props: { post } };
}

// Or Static Generation
export async function getStaticProps({ params }) {
  const post = await fetch(`https://api.com/posts/${params.slug}`).then((r) =>
    r.json()
  );
  return { props: { post } };
}

export async function getStaticPaths() {
  const posts = await fetch("https://api.com/posts").then((r) => r.json());
  return {
    paths: posts.map((p) => ({ params: { slug: p.slug } })),
    fallback: false,
  };
}

Global Layout with _app.js

// pages/_app.js
import "../styles/globals.css";
import Navbar from "../components/Navbar";
import Footer from "../components/Footer";

export default function App({ Component, pageProps }) {
  return (
    <>
      <Navbar />
      <Component {...pageProps} />
      <Footer />
    </>
  );
}

Problem: There is only one global layout. If you want different layouts by section (blog vs dashboard), you need conditional logic.

App Router: The New Era

File structure

app/
├── layout.js        # Root layout
├── page.js          # → /
├── loading.js       # Loading UI
├── error.js         # Error handling
├── about/
│   └── page.js      # → /about
├── blog/
│   ├── layout.js    # Layout only for /blog/*
│   ├── page.js      # → /blog
│   └── [slug]/
│       ├── page.js  # → /blog/post-1
│       └── loading.js
└── api/
    └── hello/
        └── route.js # → /api/hello

Server Components by Default

// app/blog/[slug]/page.js
// This is a SERVER COMPONENT (runs on the server)
export default async function BlogPost({ params }) {
  const { slug } = await params;

  // Fetch directly in the component, without getServerSideProps
  const post = await fetch(`https://api.com/posts/${slug}`, {
    next: { revalidate: 60 }, // Automatic ISR
  }).then((r) => r.json());

  return (
    <div>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
    </div>
  );
}

// generateStaticParams replaces getStaticPaths
export async function generateStaticParams() {
  const posts = await fetch("https://api.com/posts").then((r) => r.json());
  return posts.map((p) => ({ slug: p.slug }));
}

Nested Layouts

// app/layout.js (root)
export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <body>
        <Header />
        {children}
        <Footer />
      </body>
    </html>
  );
}

// app/blog/layout.js (only for /blog/*)
export default function BlogLayout({ children }) {
  return (
    <div className="blog-container">
      <aside>
        <h3>Recent Articles</h3>
      </aside>
      <main>{children}</main>
    </div>
  );
}

Advantage: Each section can have its own layout, and they nest automatically.

Automatic Loading States

// app/blog/loading.js
export default function Loading() {
  return (
    <div className="spinner">
      <p>Loading articles...</p>
    </div>
  );
}

Next.js displays this component automatically while page.js fetches data.

Per-Route Error Handling

// app/blog/error.js
"use client"; // Error boundaries must be Client Components

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

Client Components in the App Router

If you need interactivity (hooks, events, and so on), use 'use client':

// app/components/Counter.js
"use client";

import { useState } from "react";

export default function Counter() {
  const [count, setCount] = useState(0);

  return <button onClick={() => setCount(count + 1)}>Clicks: {count}</button>;
}

Data Fetching: Comparison

App Router

// SSR (by default)
async function getData() {
  const res = await fetch("https://api.com/data", { cache: "no-store" });
  return res.json();
}

// SSG
async function getData() {
  const res = await fetch("https://api.com/data", { cache: "force-cache" });
  return res.json();
}

// ISR (Incremental Static Regeneration)
async function getData() {
  const res = await fetch("https://api.com/data", {
    next: { revalidate: 60 }, // Revalidates every 60 seconds
  });
  return res.json();
}

Streaming and Suspense

The App Router supports React Server Components + Suspense, enabling streaming:

// app/dashboard/page.js
import { Suspense } from "react";

async function SlowComponent() {
  const data = await fetch("https://slow-api.com/data");
  return <div>{/* renders data */}</div>;
}

export default function Dashboard() {
  return (
    <div>
      <h1>Dashboard</h1>
      <FastContent />

      <Suspense fallback={<Skeleton />}>
        <SlowComponent />
      </Suspense>
    </div>
  );
}

The initial HTML is sent immediately, and slow components stream in when they are ready.

Which One Should You Use?

Use Pages Router if:

  • You have an existing project that works well
  • Your team is not familiar with Server Components
  • You use libraries that are not yet compatible with RSC
  • You need production-proven stability

Use App Router if:

  • You are starting a new project
  • You want better performance (Server Components reduce JavaScript)
  • You need complex, nested layouts
  • You want streaming and automatic loading states
  • You value a modern DX (direct fetch, the metadata API, and so on)

Gradual Migration

You can use both at the same time:

my-app/
├── app/
│   └── dashboard/    # New routes in App Router
│       └── page.js
└── pages/
    ├── index.js      # Existing Pages Router routes
    └── about.js

Next.js prioritizes app/ over pages/ if both define the same route.

Conclusion

  • Pages Router: Mature, stable, familiar. Perfect for existing projects.
  • App Router: Modern, efficient, with a better DX. Ideal for new projects.

Both are supported, and there is no rush to migrate. But if you're starting something new, the App Router will give you real performance and DX advantages.

Resources