Next.js has evolved significantly over the years, with the introduction of the App Router in version 13 representing one of its most substantial architectural shifts. Let's explore the key differences between the traditional Pages Router and the newer App Router approaches to help you understand which might be better suited for your projects.
Fundamental Architecture
Pages Router
The Pages Router uses a file-system based routing approach where each file in the /pages
directory automatically becomes a route. This has been Next.js's original approach since its inception.
For example, a file at /pages/products/[id].tsx
would create a dynamic route that matches paths like /products/1
, /products/2
, etc. The routing is straightforward and intuitive for developers coming from other frameworks.
App Router
The App Router, introduced in Next.js 13, uses a more complex but powerful /app
directory structure. Rather than individual files representing routes, the App Router uses folders to define routes, with special files like page.tsx
, layout.tsx
, and loading.tsx
serving specific purposes within each route segment.
This folder-based approach allows for nested layouts and more granular control over how your application renders and loads.
Component Model
Pages Router
In the Pages Router, components are primarily class or function components that export a default React component. Data fetching methods like getStaticProps
, getServerSideProps
, and getInitialProps
are separate exported functions from the same file:
// pages/products.tsx
import { GetServerSideProps } from 'next';
interface Product {
id: number;
name: string;
}
interface ProductsProps {
products: Product[];
}
export default function Products({ products }: ProductsProps) {
return (
<div>
{products.map(product => (
<div key={product.id}>{product.name}</div>
))}
</div>
);
}
export const getServerSideProps: GetServerSideProps<ProductsProps> = async () => {
const res = await fetch('https://api.example.com/products');
const products: Product[] = await res.json();
return {
props: { products }
};
}
App Router
The App Router introduces Server Components as the default, allowing components to run on the server with direct access to resources like databases. This eliminates the need for separate data fetching methods:
// app/products/page.tsx
interface Product {
id: number;
name: string;
}
async function getProducts(): Promise<Product[]> {
const res = await fetch('https://api.example.com/products');
return res.json();
}
export default async function Products() {
const products = await getProducts();
return (
<div>
{products.map(product => (
<div key={product.id}>{product.name}</div>
))}
</div>
);
}
Notice how the component itself can be async, and we directly await data inside the component. This represents a fundamental shift in how components work and interact with data.
Data Fetching
Pages Router
The Pages Router uses specific functions exported alongside your components:
-
getStaticProps
: For static generation at build time -
getServerSideProps
: For server-side rendering on each request -
getInitialProps
: The older approach for either client or server rendering
These functions run at build time or request time depending on which one you choose, with data passed to your component as props.
// pages/post/[id].tsx
import { GetStaticProps, GetStaticPaths } from 'next';
interface Post {
id: string;
title: string;
content: string;
}
interface PostProps {
post: Post;
}
export default function Post({ post }: PostProps) {
return (
<article>
<h1>{post.title}</h1>
<div>{post.content}</div>
</article>
);
}
export const getStaticPaths: GetStaticPaths = async () => {
const res = await fetch('https://api.example.com/posts');
const posts: Post[] = await res.json();
const paths = posts.map(post => ({
params: { id: post.id.toString() }
}));
return { paths, fallback: 'blocking' };
}
export const getStaticProps: GetStaticProps<PostProps> = async ({ params }) => {
const res = await fetch(`https://api.example.com/posts/${params?.id}`);
const post: Post = await res.json();
return {
props: { post },
revalidate: 60 // Regenerate page every 60 seconds
};
}
App Router
The App Router simplifies this model with:
- Direct
fetch
calls within Server Components with built-in caching and revalidation - No need for separate exported functions
- More granular control over caching behavior per request
For example:
// app/posts/[id]/page.tsx
interface Post {
id: string;
title: string;
content: string;
}
async function getPost(id: string): Promise<Post> {
const res = await fetch(`https://api.example.com/posts/${id}`, {
next: { revalidate: 60 }
});
if (!res.ok) {
throw new Error('Failed to fetch post');
}
return res.json();
}
export default async function PostPage({ params }: { params: { id: string } }) {
const post = await getPost(params.id);
return (
<article>
<h1>{post.title}</h1>
<div>{post.content}</div>
</article>
);
}
// Generate static parameters for common paths
export async function generateStaticParams() {
const posts = await fetch('https://api.example.com/posts').then(res =>
res.json()
);
return posts.map((post: Post) => ({
id: post.id.toString(),
}));
}
The caching behavior is defined directly with the fetch call, making it more intuitive and reducing the cognitive load of understanding separate data fetching functions.
Layouts and Nested Routes
Pages Router
In the Pages Router, layouts are handled through component composition. You typically create a layout component and wrap your page components with it:
// components/Layout.tsx
import React, { ReactNode } from 'react';
interface LayoutProps {
children: ReactNode;
}
export default function Layout({ children }: LayoutProps) {
return (
<div>
<header>My Site</header>
<main>{children}</main>
<footer>Copyright 2025</footer>
</div>
);
}
// pages/about.tsx
import Layout from '../components/Layout';
export default function About() {
return (
<Layout>
<h1>About Us</h1>
<p>This is the about page</p>
</Layout>
);
}
This approach works but requires you to manually include the Layout in every page component.
App Router
The App Router takes a more structured approach with dedicated layout.tsx
files that automatically wrap all child routes:
// app/layout.tsx
import { ReactNode } from 'react';
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body>
<header>My Site</header>
<main>{children}</main>
<footer>Copyright 2025</footer>
</body>
</html>
);
}
// app/about/page.tsx
export default function About() {
return (
<>
<h1>About Us</h1>
<p>This is the about page</p>
</>
);
}
Layouts can be nested within folders to create more complex layout hierarchies without needing to manage them manually in each component.
Loading States and Error Handling
Pages Router
Loading states and error handling in the Pages Router typically require manual implementation using React state:
// pages/products.tsx
import { useState, useEffect } from 'react';
interface Product {
id: number;
name: string;
}
export default function Products() {
const [products, setProducts] = useState<Product[]>([]);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
fetch('/api/products')
.then(res => {
if (!res.ok) throw new Error('Failed to fetch');
return res.json();
})
.then((data: Product[]) => {
setProducts(data);
setLoading(false);
})
.catch(err => {
setError(err);
setLoading(false);
});
}, []);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<div>
{products.map(product => (
<div key={product.id}>{product.name}</div>
))}
</div>
);
}
App Router
The App Router introduces specialized files for loading states (loading.tsx
) and error handling (error.tsx
):
// app/products/loading.tsx
export default function Loading() {
return <div>Loading products...</div>;
}
// app/products/error.tsx
'use client';
import { useEffect } from 'react';
interface ErrorComponentProps {
error: Error;
reset: () => void;
}
export default function Error({ error, reset }: ErrorComponentProps) {
useEffect(() => {
// Log the error to an error reporting service
console.error(error);
}, [error]);
return (
<div>
<h2>Something went wrong loading products!</h2>
<p>{error.message}</p>
<button onClick={reset}>Try again</button>
</div>
);
}
// app/products/page.tsx
interface Product {
id: number;
name: string;
}
async function getProducts(): Promise<Product[]> {
const res = await fetch('https://api.example.com/products');
if (!res.ok) throw new Error('Failed to fetch products');
return res.json();
}
export default async function Products() {
const products = await getProducts();
return (
<div>
{products.map(product => (
<div key={product.id}>{product.name}</div>
))}
</div>
);
}
This approach automatically renders the loading state while the page loads and the error component if an error occurs, without needing to handle those states in every component.
Route Handlers (API Routes)
Pages Router
In the Pages Router, API routes are defined in the /pages/api
directory:
// pages/api/products.ts
import type { NextApiRequest, NextApiResponse } from 'next';
interface Product {
id: number;
name: string;
}
export default function handler(
req: NextApiRequest,
res: NextApiResponse<Product[] | { message: string }>
) {
if (req.method === 'GET') {
const products: Product[] = [
{ id: 1, name: 'Product 1' },
{ id: 2, name: 'Product 2' }
];
res.status(200).json(products);
} else {
res.status(405).json({ message: 'Method Not Allowed' });
}
}
App Router
The App Router moves API routes to a route.ts
file using a more modern approach:
// app/api/products/route.ts
import { NextResponse } from 'next/server';
interface Product {
id: number;
name: string;
}
export async function GET() {
const products: Product[] = [
{ id: 1, name: 'Product 1' },
{ id: 2, name: 'Product 2' }
];
return NextResponse.json(products);
}
export async function POST(request: Request) {
const body: Product = await request.json();
// Process the data
console.log('Received product:', body);
return NextResponse.json(
{ message: 'Product created', product: body },
{ status: 201 }
);
}
This approach uses the standard Web Response API rather than the Express-like request/response model, making it more aligned with web standards.
Client-Side Navigation
Pages Router
The Pages Router uses <Link>
components for client-side navigation, but requires manually prefetching routes:
// components/Navigation.tsx
import Link from 'next/link';
export default function Navigation() {
return (
<nav>
<Link href="/" prefetch={true}>Home</Link>
<Link href="/about" prefetch={false}>About</Link>
</nav>
);
}
App Router
The App Router still uses the <Link>
component but handles prefetching more intelligently by default:
// app/components/Navigation.tsx
import Link from 'next/link';
export default function Navigation() {
return (
<nav>
<Link href="/">Home</Link>
<Link href="/about">About</Link>
</nav>
);
}
Prefetching happens automatically for links in the viewport, optimizing for performance without requiring explicit configuration.
Server vs. Client Components
Pages Router
All components in the Pages Router are client components by default, meaning they run on both the server (for initial render) and the client (for interactions and updates).
App Router
The App Router introduces a distinction between Server Components (default) and Client Components (opt-in):
// app/server-component/page.tsx
// This is a Server Component (default)
interface DataType {
title: string;
}
export default async function ServerComponent() {
const data = await fetch('https://api.example.com/data');
const result: DataType = await data.json();
return <div>{result.title}</div>;
}
// app/components/counter.tsx
// This is a Client Component (opt-in)
'use client';
import { useState } from 'react';
export default function ClientComponent() {
const [count, setCount] = useState<number>(0);
return (
<button
onClick={() => setCount(count + 1)}
className="px-4 py-2 bg-blue-500 text-white rounded"
>
Count: {count}
</button>
);
}
This distinction allows for more optimized rendering and better performance by keeping as much as possible on the server.
Conclusion
The shift from Pages Router to App Router represents Next.js's evolution toward a more structured, performance-focused framework. While the Pages Router offers simplicity and familiarity, the App Router provides more powerful features like nested layouts, server components, and simplified data fetching.
Next.js Router Comparison Table
Feature | Pages Router | App Router | |
---|---|---|---|
Directory Structure |
/pages directory with each file representing a route |
/app directory with folders defining routes and special files (page.tsx , layout.tsx , etc.) |
|
Component Model | Client components by default | Server components by default, client components opt-in with 'use client' directive |
|
Data Fetching | Separate functions (getStaticProps , getServerSideProps ) |
Direct fetch calls with built-in caching inside components |
|
Layouts | Manual composition through component wrapping | Automatic through layout.tsx files with nested inheritance |
|
Loading States | Manual implementation with React state | Automatic with loading.tsx files |
|
Error Handling | Manual try-catch blocks | Automatic with error.tsx files |
|
API Routes |
/pages/api with req /res pattern |
/app/api with HTTP method exports (GET , POST ) |
|
Route Handlers | Express-like pattern with req /res
|
Web standard Response API | |
Client Navigation | Manual prefetching configuration | Intelligent automatic prefetching | |
TypeScript Support | Built-in, but requires manual type annotations | Built-in with better type inference for routes | |
Performance | Good, but requires manual optimization | Better by default due to server components | |
Parallel Routes | Not supported | Supported via folder naming convention | |
Intercepting Routes | Not supported | Supported via special naming convention | |
Metadata | Requires manual <Head> component |
Built-in metadata object or generateMetadata function |
|
Streaming | Limited support | First-class support with streaming SSR | |
Coexistence | Can exist alongside App Router | Can exist alongside Pages Router |
For new projects, the App Router is generally recommended as it represents the future direction of Next.js. For existing projects, migration can be gradual as both routers can coexist in the same application.
The App Router offers several advantages:
- Better performance through automatic optimizations
- More intuitive API design aligned with modern web standards
- Reduced boilerplate code for common patterns
- Enhanced TypeScript support with better type inference
- Improved developer experience with more granular control
However, the Pages Router still has its place, especially for:
- Existing projects where migration costs may be significant
- Simpler applications that don't need the advanced features
- Developers more comfortable with the traditional React patterns
- Projects using libraries that haven't been updated for Server Components
Understanding these key differences will help you make informed decisions about which approach to use for your Next.js applications and how to best leverage the framework's capabilities.
Top comments (0)