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

Introduction

When the first version of Next.js was released in 2016, it came with a filesystem-based routing system called the Pages Router. It uses a specific pages directory for defining routes. In Next.js 13, the App Router was introduced as a more modern alternative that uses a app directory for route organization, and providing support for newer React features like Server Components, Server Functions, and Suspense. Both are fully supported today, and choosing between them is one of the first decisions you'll make when starting a new Next.js project.

On the surface, the two routers achieve the same goal: they map files in your project to URL routes. But under the hood, they have fundamentally different architectures, data-fetching patterns, and mental models. Understanding these differences will help you make better architectural decisions, avoid common pitfalls, and write more efficient Next.js applications.

In this article, you'll learn the key differences between the App Router and the Pages Router, when to use each, and how to think about migrating if you're working on an existing project.

How routing works

The most visible difference between the two routers is how files map to routes.

Pages router

In the Pages Router, any React file (.js, .ts, .jsx, or .tsx) you create inside the pages/ directory automatically becomes a route. You can also use folders to group pages, but the file name is the route.

pages/
├── index.tsx → example.com/
├── about.tsx → example.com/about
├── blog/
│ ├── index.tsx → example.com/blog
│ └── [slug].tsx → example.com/blog/:slug

Dynamic routes use bracket notation [slug].tsx, and catch-all routes use spread notation [...slug].tsx.

App router

The App Router, on the other hand, lives inside the app/ directory. The key difference is that folder names define routes, and a special file called page.tsx inside that folder defines the UI for that route.


app/
├── page.tsx → /
├── about/
│ └── page.tsx → /about
├── blog/
│ ├── page.tsx → /blog
│ └── [slug]
│ └── page.tsx → /blog/:slug

This might seem like more boilerplate at first glance, but the folder-based approach unlocks a more powerful feature: co-location. You can place components, tests, and utilities directly alongside the route files that use them, without accidentally exposing them as routes.

Layouts

Layouts are one of the biggest practical differences between the two routers.

Pages router

One major limitation of the Pages Router is its lack of built-in nested layouts like the App Router. You have to manually implement nested layouts through a global layout defined in _app.tsx (which is not what you want in most cases) or the getLayout pattern.

Suppose you have the following dashboard structure:

pages/
├── dashboard/
│ ├── index.tsx 
│ ├── settings.tsx 

Ideally, both pages would share a common DashboardLayout containing elements such as a sidebar and navbar. To achieve that with the Pages Router, you have to manually import the layout and attach the getLayout in every single page.

Without a shared layout pattern, navigating between dashboard/index.tsx and dashboard/settings.tsx causes the current page component to unmount and the next page component to mount. Any local React state stored within the page or its layout components is therefore lost between navigations.

App router

The App Router makes nested layouts a first-class feature. Any layout.tsx file in a folder automatically wraps the routes inside that folder, and layouts persist across navigations, meaning they don't remount when a user navigates between child routes.

app/
├── layout.tsx ← root layout (wraps everything)
├── page.tsx
└── dashboard/
├── layout.tsx ← dashboard layout (persists within /dashboard/*)
├── page.tsx
└── settings/
└── page.tsx

Some developers find the file naming conventions used by the App Router less convenient when navigating large codebases:

page.tsx
layout.tsx
loading.tsx
error.tsx
route.ts

I agree that having multiple identical file names in a codebase can cause visual clutter and make debugging harder in code editors.

However, I also think that this issue can be mitigated by using specific editor features like tab naming configurations. For example, VS Code allows you to change workbench.editor.labelFormat to medium or short to display parent folder names on tabs.

Data fetching

Data fetching is one of the most commonly debated issues in Next.js, and it's also where the two router methodss diverge the most.

Pages router

The Pages Router provides three special data fetching functions that you export from page files:

  • Static Site Generation (SSG)getStaticProps: Fetches data at build time and generates a static HTML page.
  • Server-Side Rendering (SSR)getServerSideProps: Fetches data on each request and renders the page on the server.
  • Dynamic Routes with SSGgetStaticPaths: Works with getStaticProps to specify which dynamic pages should be pre-rendered at build time.

Additionally, the Pages Router supports dynamic data fetching if you don't need SEO optimization and your data refreshes dynamically inside the browser view:

  • Client-Side Rendering (CSR) – Uses fetch, useEffect, or libraries like SWR to fetch data in the browser after the page loads.
// pages/blog/[slug].tsx

import { useEffect, useState } from 'react';

// SSG: Fetch data at build time
export async function getStaticProps({ params }) {
  const post = await fetchPost(params.slug);

  return {
    props: { post },
  };
}

// SSG: Define dynamic routes to pre-render
export async function getStaticPaths() {
  const slugs = await fetchAllSlugs();

  return {
    paths: slugs.map((slug) => ({
      params: { slug },
    })),
    fallback: false,
  };
}

export default function BlogPost({ post }) {
  const [comments, setComments] = useState([]);

  // CSR: Fetch comments in the browser after hydration
  useEffect(() => {
    async function loadComments() {
      const res = await fetch(`/api/comments?slug=${post.slug}`);
      const data = await res.json();
      setComments(data);
    }

    loadComments();
  }, [post.slug]);

  return (
    <>
      <article>{post.content}</article>

      <section>
        <h2>Comments</h2>
        {comments.map((comment) => (
          <p key={comment.id}>{comment.body}</p>
        ))}
      </section>
    </>
  );
}

In this example, the blog post is statically generated using getStaticProps, dynamic routes are defined with getStaticPaths, and comments are fetched on the client after hydration using fetch and useEffect.

App router

The App Router replaces the three data fetching functions with async Server Components. Because Server Components run exclusively on the server, you can fetch data directly inside the component as no special function is needed.

// app/blog/[slug]/page.tsx

export default async function BlogPost({ params }) {
const post = await fetchPost(params.slug);

return <article>{post.content}</article>;
}

For static generation with dynamic routes, you export a generateStaticParams function (the equivalent of getStaticPaths):

export async function generateStaticParams() {
const slugs = await fetchAllSlugs();
return slugs.map((slug) => ({ slug }));
}

For cache control, which the Pages Router handled via revalidate in getStaticProps, the App Router uses the native fetch API with a next option:

const data = await fetch('https://api.example.com/posts', {
next: { revalidate: 3600 }, // revalidate every hour
});

The revalidate option works the same way as revalidate in getStaticProps from the Pages Router. It tells Next.js how long to cache the response before re-fetching it in the background.

React server components

React Server Components (RSCs), introduced in React 19, are a new type fo Component that allows you to render UI components exclusively on the server, sending zero client-side JavaScript for those components to the browser. This rendering happens ahead of time, before bundling, in an environment separate from your client app or SSR server.

RSCs is the most architecturally significant difference between the Pages and App Router, and it stems directly from how the App Router works.

What are Server Components?

React Server Components (RSC) are components that render exclusively on the server. They have direct access to databases, file systems, and secrets, without exposing any of it to the client. They produce zero JavaScript in the client bundle, which improves performance.

In the App Router, all components are Server Components by default. To opt a component into client-side rendering (event handlers, state, browser APIs), you must add the 'use client' directive at the top of the file:


'use client';

import { useState } from 'react';

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

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

Pages router

The Pages Router does not have Server Components. All components are client components by default. Data fetching is separated out into special functions likegetStaticProps or getServerSideProps, and components themselves always run on the client.

This distinction matters because in the App Router, you need to be intentional about what runs where. A common mistake is trying to use hooks (useState, useEffect) in a Server Component, the compiler will throw an error until you add 'use client'.

A useful mental model: if a component needs to be interactive or access browser APIs, it's a Client Component. If it just renders data, it can be a Server Component.

API routes

Both routers support API routes: server-side endpoints that handle HTTP requests.

Pages router

API routes live in pages/api/ and export a handler function:


// pages/api/hello.ts
import type { NextApiRequest, NextApiResponse } from 'next'; 

export default function handler(req: NextApiRequest, res: NextApiResponse) {
res.status(200).json({ message: 'Hello' });

}

App router

The App Router uses Route Handlers, which live in route.ts files inside the app/ directory. They use the Web standard Request and Response objects:


// app/api/hello/route.ts
export async function GET(request: Request) {
return Response.json({ message: 'Hello' });
}

Route Handlers support named exports for each HTTP method (GET, POST, PUT, DELETE, etc.), which is cleaner than the req.method switch statement pattern common in Pages Router API routes.


export async function GET() { /* ... */ }
export async function POST(request: Request) { /* ... */ }
export async function DELETE(request: Request) { /* ... */ }

Middleware

Middleware works the same way in both routers: you create a middleware.ts file at the root of your project and export a middleware function. The API is identical regardless of which router you're using.

// middleware.ts

import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(request: NextRequest) {

if (!request.cookies.get('token')) {
return NextResponse.redirect(new URL('/login', request.url));
}

return NextResponse.next();
}

export const config = {
matcher: '/dashboard/:path*',
};

Special files

Each router has its own set of special files that handle specific behaviours.

PurposePages RouterApp Router
Root HTML shell_document.tsxlayout.tsx
Global wrapper_app.tsxlayout.tsx
404 page404.tsxnot-found.tsx
Error boundary_error.tsxerror.tsx
Loading stateloading.tsx
Route component[name].tsxpage.tsx

The App Router's loading.tsx is particularly useful. It automatically renders a loading UI using React Suspense while a page or layout is fetching data, without any manual Suspense boundary setup.

When to use each

As of today, the Next.js team recommends using the App Router for new Next.js projects. They made it clear that App Router is the future of the framework, and most new features are being built exclusively for it.

Use the Pages Router when:

  • You're maintaining an existing Pages Router project; there's no obligation to migrate

  • Your team is more comfortable with the traditional model

  • You're using third-party libraries or patterns that haven't fully adopted Server Components yet

  • Your project is a straightforward marketing site or blog with simple data fetching

Use the App Router when:

  • You're starting a new project today.
  • You need fine-grained control over which parts of the UI render on the server vs. client.
  • You're building a complex application with nested layouts, streaming, or incremental rendering.
  • You want to take advantage of Server Components to reduce client bundle size.

How to migrate from Pages to App router

Next.js officially supports running both routers in the same project during migration. The pages/ and app/ directories can coexist, so you can migrate incrementally, route by route, without a single rewrite.

The general migration steps are:

  1. Create the app/ directory at the root of your project.
  2. Create a root app/layout.tsx that includes the <html> and <body> tags (this replaces _document.tsx).
  3. Move global styles and providers from _app.tsx into the root layout (app/layout.tsx).
  4. Migrate next/head usage to the App Router Metadata API.
    • In the Pages Router, next/head is used to manage <head> elements like titles and meta tags.
    • In the App Router, it is replaced by the built-in Metadata API (export const metadata) for static metadata or generateMetadata for dynamic cases.
  5. Migrate pages one at a time, starting with the simplest ones.
  6. Convert getStaticProps / getServerSideProps calls to async component fetches or generateStaticParams.
  7. Audit components for useState / useEffect usage and add 'use client' where needed.

The most common migration pain points are:

  • Context providers: They need to be in a Client Component, so wrap them in a 'use client' component and import that into the Server Component layout.
  • Third-party libraries: Some older libraries don't yet support Server Components. Check the library's documentation before migrating.
  • Cookies and headers: cookies() and headers() from next/headers replace req.cookies and req.headers from getServerSideProps.

Conclusion

The Pages Router and the App Router are both mature, production-ready options. The Pages Router is simpler, battle-tested, and well-documented with years of community resources behind it. The App Router is more powerful, more flexible, and where the framework is heading. But it comes with a steeper learning curve, particularly around the Server/Client Component boundary.

If you're building a new Next.js application today, start with the App Router. If you're maintaining an existing project, migrate when the complexity of your application genuinely calls for it, not because a new version was released.