Next.js 13 introduced the App Router alongside the existing Pages Router. Three years later, the App Router is the default and the recommended path for new projects. But the Pages Router still works, still receives maintenance updates, and is often the right choice in specific situations. This guide explains the real differences so you can make an informed decision.
The Core Difference: Server Components
The fundamental shift in the App Router is React Server Components (RSC). In the Pages Router, all components are client components — they ship JavaScript to the browser and can run on the client. In the App Router, components are server components by default — they render on the server, ship no JavaScript to the browser, and can directly access databases, file systems, and environment variables.
// App Router — app/posts/page.tsx
// This is a Server Component by default — no "use client", no useEffect, no loading state
import { prisma } from '@/lib/prisma'
export default async function PostsPage() {
// Fetch directly in the component — runs on the server, never in the browser
const posts = await prisma.post.findMany({
where: { published: true },
orderBy: { createdAt: 'desc' },
})
return (
<ul>
{posts.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)
}
// Pages Router equivalent — pages/posts.tsx
// Must use getServerSideProps to fetch on the server
import { prisma } from '@/lib/prisma'
import type { GetServerSideProps } from 'next'
export const getServerSideProps: GetServerSideProps = async () => {
const posts = await prisma.post.findMany({ where: { published: true } })
return { props: { posts } }
}
export default function PostsPage({ posts }) {
return (
<ul>
{posts.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)
}
File Structure Comparison
Pages Router Structure
pages/
├── index.tsx # → /
├── about.tsx # → /about
├── blog/
│ ├── index.tsx # → /blog
│ └── [slug].tsx # → /blog/my-post
├── api/
│ └── users.ts # → /api/users (API route)
└── _app.tsx # global layout wrapper
App Router Structure
app/
├── page.tsx # → /
├── layout.tsx # root layout (replaces _app.tsx)
├── about/
│ └── page.tsx # → /about
├── blog/
│ ├── page.tsx # → /blog
│ └── [slug]/
│ └── page.tsx # → /blog/my-post
└── api/
└── users/
└── route.ts # → /api/users (Route Handler)
Data Fetching Patterns
Pages Router: Three Functions
// getStaticProps — build time (SSG)
export const getStaticProps: GetStaticProps = async () => {
const data = await fetch('...').then(r => r.json())
return { props: { data }, revalidate: 60 } // ISR: regenerate every 60s
}
// getStaticPaths — required for dynamic SSG routes
export const getStaticPaths: GetStaticPaths = async () => {
const posts = await prisma.post.findMany({ select: { slug: true } })
return {
paths: posts.map(p => ({ params: { slug: p.slug } })),
fallback: 'blocking',
}
}
// getServerSideProps — request time (SSR)
export const getServerSideProps: GetServerSideProps = async (ctx) => {
const session = await getSession(ctx)
if (!session) return { redirect: { destination: '/login', permanent: false } }
return { props: { user: session.user } }
}
App Router: Fetch + Cache Directives
// SSG — static by default, cached forever
async function StaticPage() {
const data = await fetch('https://api.example.com/data').then(r => r.json())
return <div>{data.title}</div>
}
// ISR — revalidate every 60 seconds
async function ISRPage() {
const data = await fetch('https://api.example.com/data', {
next: { revalidate: 60 }
}).then(r => r.json())
return <div>{data.title}</div>
}
// SSR — no cache (fresh on every request)
async function SSRPage() {
const data = await fetch('https://api.example.com/data', {
cache: 'no-store'
}).then(r => r.json())
return <div>{data.title}</div>
}
// Route segment config (alternative syntax)
export const revalidate = 60 // ISR for entire route
export const dynamic = 'force-dynamic' // SSR for entire route
Layouts
Pages Router: _app.tsx
// pages/_app.tsx — wraps every page
export default function App({ Component, pageProps }) {
return (
<AuthProvider>
<Header />
<Component {...pageProps} />
<Footer />
</AuthProvider>
)
}
App Router: Nested Layouts
// app/layout.tsx — root layout
export default function RootLayout({ children }) {
return (
<html lang="en">
<body>
<Header />
{children}
<Footer />
</body>
</html>
)
}
// app/dashboard/layout.tsx — nested layout for /dashboard/*
// Persists between dashboard page navigations without re-rendering
export default function DashboardLayout({ children }) {
return (
<div className="dashboard">
<Sidebar />
<main>{children}</main>
</div>
)
}
Nested layouts are one of App Router's biggest practical advantages — a sidebar that doesn't unmount when you navigate between dashboard pages, shared state preserved across subroutes, and per-section loading/error states.
Client Components in App Router
When you need interactivity (useState, useEffect, event handlers), add 'use client' at the top of the file:
'use client'
import { useState } from 'react'
export default function SearchBar() {
const [query, setQuery] = useState('')
return (
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Search..."
/>
)
}
The pattern is to keep Server Components as the outer shell (data fetching, layout) and push interactivity into small Client Components at the leaves of the tree. This maximizes the amount of code that stays server-side.
API Routes vs Route Handlers
// Pages Router: pages/api/users.ts
import type { NextApiRequest, NextApiResponse } from 'next'
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === 'GET') {
const users = await prisma.user.findMany()
res.json(users)
} else if (req.method === 'POST') {
const user = await prisma.user.create({ data: req.body })
res.status(201).json(user)
}
}
// App Router: app/api/users/route.ts
import { NextRequest, NextResponse } from 'next/server'
export async function GET() {
const users = await prisma.user.findMany()
return NextResponse.json(users)
}
export async function POST(req: NextRequest) {
const body = await req.json()
const user = await prisma.user.create({ data: body })
return NextResponse.json(user, { status: 201 })
}
Loading, Error, and Not Found States
App Router has built-in file conventions for these:
app/blog/
├── page.tsx # the page
├── loading.tsx # shown while page.tsx is streaming (Suspense boundary)
├── error.tsx # shown if page.tsx throws ('use client' required)
└── not-found.tsx # shown when notFound() is called in page.tsx
// app/blog/loading.tsx
export default function Loading() {
return <div className="skeleton">Loading posts...</div>
}
// app/blog/error.tsx
'use client'
export default function Error({ error, reset }) {
return (
<div>
<h2>Something went wrong</h2>
<button onClick={reset}>Retry</button>
</div>
)
}
When to Use Each
Choose App Router for new projects if:
- Starting a new Next.js project (always — it's the default and the future)
- You want Server Components to reduce client-side JavaScript
- You need nested persistent layouts (dashboard with sidebar, multi-step forms)
- You're building with React 18+ features (Suspense streaming, concurrent rendering)
- TypeScript is your stack (App Router's type inference is better)
Stick with Pages Router if:
- Existing large codebase that would require significant migration effort
- Heavy reliance on third-party libraries not yet compatible with Server Components (some auth libraries, older state management)
- Your team isn't yet comfortable with the Server Component mental model
- Simple sites where the App Router's complexity isn't justified
Summary
App Router is the right choice for all new Next.js projects in 2026. Server Components reduce JavaScript bundle size, nested layouts solve a real pain that Pages Router couldn't handle elegantly, and file-based loading/error/not-found states clean up a lot of boilerplate. The Pages Router isn't going away — Vercel committed to maintaining it — but the ecosystem, documentation, and innovation is all moving toward App Router.
CREESOL