DEV Community

Cover image for Next.js Development: Key Differences Between App Router and Pages Router
Marie Berezhna
Marie Berezhna

Posted on

1

Next.js Development: Key Differences Between App Router and Pages Router

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 }
  };
}

Enter fullscreen mode Exit fullscreen mode

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>
  );
}

Enter fullscreen mode Exit fullscreen mode

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
  };
}

Enter fullscreen mode Exit fullscreen mode

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(),
  }));
}

Enter fullscreen mode Exit fullscreen mode

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>
  );
}

Enter fullscreen mode Exit fullscreen mode

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>
    </>
  );
}

Enter fullscreen mode Exit fullscreen mode

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>
  );
}

Enter fullscreen mode Exit fullscreen mode

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>
  );
}

Enter fullscreen mode Exit fullscreen mode

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' });
  }
}

Enter fullscreen mode Exit fullscreen mode

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 }
  );
}

Enter fullscreen mode Exit fullscreen mode

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>
  );
}

Enter fullscreen mode Exit fullscreen mode

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>
  );
}

Enter fullscreen mode Exit fullscreen mode

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>;
}

Enter fullscreen mode Exit fullscreen mode
// 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>
  );
}

Enter fullscreen mode Exit fullscreen mode

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:

  1. Better performance through automatic optimizations
  2. More intuitive API design aligned with modern web standards
  3. Reduced boilerplate code for common patterns
  4. Enhanced TypeScript support with better type inference
  5. Improved developer experience with more granular control

However, the Pages Router still has its place, especially for:

  1. Existing projects where migration costs may be significant
  2. Simpler applications that don't need the advanced features
  3. Developers more comfortable with the traditional React patterns
  4. 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.

Heroku

Built for developers, by developers.

Whether you're building a simple prototype or a business-critical product, Heroku's fully-managed platform gives you the simplest path to delivering apps quickly — using the tools and languages you already love!

Learn More

Top comments (0)

👋 Kindness is contagious

Engage with a wealth of insights in this thoughtful article, valued within the supportive DEV Community. Coders of every background are welcome to join in and add to our collective wisdom.

A sincere "thank you" often brightens someone’s day. Share your gratitude in the comments below!

On DEV, the act of sharing knowledge eases our journey and fortifies our community ties. Found value in this? A quick thank you to the author can make a significant impact.

Okay