Next.js App Router vs Pages Router: A Practical Guide (2026)

Next.js App Router vs Pages Router: A Practical Guide (2026)

Mykola Nesterchuk - March 11, 2026

#nextjs
#react
#performance

Introduction

Next.js now ships with two completely different ways to build your app. The Pages Router has been around since the beginning - stable, predictable, well-documented. The App Router arrived in Next.js 13 and changed almost everything: how you fetch data, how layouts work, what runs on the server, and how caching behaves.

If you're starting a new project, you need to pick one. If you're maintaining an existing codebase, you'll eventually be asked whether to migrate. This guide gives you a practical picture of both - not a surface-level comparison, but the real differences that affect how you architect and debug your app.

How Routing Works

Both routers are file-system based, but the conventions are different.

In the Pages Router, every file inside pages/ becomes a route. The filename maps directly to the URL:

pages/
  index.tsx         → /
  about.tsx         → /about
  blog/[slug].tsx   → /blog/:slug
  api/users.ts      → /api/users

In the App Router, routes are defined by folders inside app/. The actual page lives in a page.tsx file inside that folder:

app/
  page.tsx              → /
  about/
    page.tsx            → /about
  blog/
    [slug]/
      page.tsx          → /blog/:slug
  api/
    users/
      route.ts          → /api/users

The folder-based approach feels more verbose at first but unlocks co-location - you can put loading.tsx, error.tsx, and layout.tsx next to each page.tsx and they apply automatically to that route segment.

Data Fetching

This is where the two routers diverge most significantly.

Pages Router: Special Functions

In the Pages Router, data fetching happens through exported async functions that Next.js calls at request or build time:

// Pages Router export async function getServerSideProps(context) { const user = await fetchUser(context.params.id); return { props: { user } }; } export async function getStaticProps() { const posts = await fetchPosts(); return { props: { posts }, revalidate: 60 }; } export default function Page({ user }) { return <Profile user={user} />; }

The rules are clear: getServerSideProps runs on every request (SSR), getStaticProps runs at build time (SSG), and getStaticPaths defines which dynamic routes to pre-render. These functions only run in page-level files - you can't use them inside a component.

App Router: async Server Components

In the App Router, you fetch data directly inside the component. Server Components are async by default:

// App Router export default async function Page({ params }) { const user = await fetchUser(params.id); return <Profile user={user} />; }

No boilerplate, no prop drilling from the page down. Any Server Component anywhere in the tree can fetch its own data:

// This component fetches its own data - no props needed from the parent async function RecentPosts() { const posts = await fetchRecentPosts(); return ( <ul> {posts.map(post => <li key={post.id}>{post.title}</li>)} </ul> ); }

For SSG-equivalent behavior, use fetch with default caching. For SSR, add cache: 'no-store'. For ISR, use next: { revalidate: 60 }:

// Static (build time) const data = await fetch('https://api.example.com/posts'); // Dynamic (every request) const data = await fetch('https://api.example.com/user', { cache: 'no-store' }); // ISR (revalidate every 60 seconds) const data = await fetch('https://api.example.com/posts', { next: { revalidate: 60 } });

Server Components vs Client Components

The App Router introduces a split that doesn't exist in the Pages Router. By default, every component in app/ is a Server Component - it runs on the server, never ships its code to the browser, and can be async.

When you need interactivity - state, event handlers, browser APIs - you add 'use client' 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(c => c + 1)}>{count}</button>; }

The key mental model: 'use client' marks a boundary, not a component type. Everything below that boundary in the component tree also becomes a Client Component. This means you want to push 'use client' as deep as possible - keep the fetching and rendering on the server, only hydrate the interactive parts.

// Server Component - fetches data, renders the static parts export default async function PostPage({ params }) { const post = await fetchPost(params.slug); return ( <article> <h1>{post.title}</h1> <p>{post.content}</p> {/* Only this small component needs to be a Client Component */} <LikeButton postId={post.id} initialLikes={post.likes} /> </article> ); }

A common mistake is putting 'use client' at the top of a layout or page file and unintentionally sending everything to the client.

Layouts

Layouts are one of the strongest arguments for the App Router.

In the Pages Router, shared layouts require a manual wrapper pattern in _app.tsx. Nested layouts - different layout per section - need extra boilerplate and are error-prone:

// Pages Router - manual layout wrapping in _app.tsx export default function App({ Component, pageProps }) { const Layout = Component.layout || DefaultLayout; return <Layout><Component {...pageProps} /></Layout>; }

In the App Router, layouts are a first-class feature. A layout.tsx file wraps all routes in that folder and persists across navigation - it doesn't re-render when the user navigates between child routes:

// app/dashboard/layout.tsx // This layout renders once and persists across /dashboard/*, /dashboard/settings, etc. export default function DashboardLayout({ children }) { return ( <div className="dashboard"> <Sidebar /> <main>{children}</main> </div> ); }

Nested layouts compose naturally:

app/
  layout.tsx          ← root layout (html, body, global nav)
  dashboard/
    layout.tsx        ← dashboard layout (sidebar)
    page.tsx
    settings/
      layout.tsx      ← settings layout (tabs)
      page.tsx

Each level only re-renders when its own segment changes. This is a significant UX improvement for apps with complex navigation.

Caching - Where App Router Gets Complex

The App Router has four separate caching layers, and this is where many developers run into unexpected behavior.

  • Request memoization - deduplicates identical fetch() calls within a single render pass
  • Data Cache - persists fetch responses based on your cache/revalidate options
  • Full Route Cache - stores the rendered HTML/RSC payload for static routes
  • Router Cache - client-side cache that preserves component state across navigations

The upside: when configured correctly, caching is extremely efficient. The downside: debugging why data isn't updating requires understanding which cache layer is holding stale data, and the mental overhead is real.

The Pages Router is simpler here. getServerSideProps always runs on request with no caching. getStaticProps with revalidate handles ISR. There are no surprises.

Honest caveat: Some developers report higher TTFB with the App Router compared to Pages Router for SSR-heavy pages. The overhead of RSC serialization is real - profile your specific app before assuming App Router is always faster.

Parallel and Sequential Data Fetching

In the App Router, if you await multiple fetches sequentially in a component, you create a waterfall:

// ❌ Sequential - each waits for the previous const user = await fetchUser(id); const posts = await fetchUserPosts(id);

Fetch them in parallel instead:

// ✅ Parallel - both fire at the same time const [user, posts] = await Promise.all([ fetchUser(id), fetchUserPosts(id), ]);

Or split them into separate components and wrap each with <Suspense> - that way each part of the UI renders as soon as its own data arrives:

export default function Page({ params }) { return ( <> <Suspense fallback={<UserSkeleton />}> <UserProfile id={params.id} /> </Suspense> <Suspense fallback={<PostsSkeleton />}> <UserPosts id={params.id} /> </Suspense> </> ); }

This streaming pattern is one of the App Router's genuine performance wins - users see content progressively instead of waiting for the slowest fetch.

When to Use Which

SituationRecommendation
New projectApp Router
Existing Pages Router projectKeep Pages Router, migrate incrementally
Complex nested layoutsApp Router
Simple marketing site or blogEither - Pages Router is simpler
Server-heavy data fetchingApp Router (Server Components)
Heavy client interactivityEither - both support client-side rendering
Team unfamiliar with RSCPages Router until the team is ready
Need stable, battle-tested patternsPages Router

Migrating from Pages to App Router

Next.js supports both routers in the same project. You can migrate incrementally by keeping pages/ working while adding new routes to app/:

  1. Create the app/ directory alongside your existing pages/
  2. Add a root app/layout.tsx (required)
  3. Move routes one at a time - start with the simplest pages
  4. Replace getServerSideProps with async Server Components
  5. Replace getStaticProps with default cached fetch calls
  6. Move client-side state and event handlers to components marked 'use client'

The biggest conceptual shift isn't the syntax - it's thinking in terms of the Server/Client boundary rather than page-level data fetching functions.

Summary

Both routers are production-ready and supported. Here's the practical takeaway:

  • Pages Router is simpler, more predictable, and easier to debug. Still a good choice for teams that need to move fast without learning new mental models.
  • App Router gives you co-located data fetching, persistent nested layouts, streaming with Suspense, and a smaller client bundle. Worth the learning curve for new projects.
  • The Server/Client boundary is the core concept to internalize in the App Router - push 'use client' as deep as possible.
  • Caching in the App Router is powerful but complex - budget time to understand it before shipping to production.
  • You don't have to choose all at once. The two routers coexist in the same Next.js project.

Table of Contents