Next.js

App Router vs Pages Router

25 min Lesson 4 of 40

Understanding the Two Routing Systems

Next.js currently supports two routing systems: the traditional Pages Router and the new App Router introduced in Next.js 13. Understanding both systems is crucial because you'll encounter legacy projects using Pages Router, while new projects should typically use the App Router for its advanced features and better performance.

Important: Both routing systems can coexist in the same project during migration. Next.js will prioritize the App Router when both exist. However, for new projects, it's recommended to use only the App Router.

Pages Router Overview

The Pages Router has been Next.js's routing system since its inception. It's stable, well-documented, and widely used in production applications.

Pages Router Characteristics

  • Location: Routes live in the pages/ directory
  • File-Based: Each file directly represents a route
  • Rendering: Client-side rendering by default, with opt-in SSR and SSG
  • Data Fetching: Uses getServerSideProps, getStaticProps, and getStaticPaths
  • Layouts: Implemented manually through the _app.js file

Pages Router Structure Example

pages/
├── _app.js           # Custom App component (global layout)
├── _document.js      # Custom Document (HTML structure)
├── index.js          # Home page (/)
├── about.js          # About page (/about)
└── blog/
    ├── index.js      # Blog home (/blog)
    └── [slug].js     # Dynamic blog post (/blog/:slug)

Pages Router Example

// pages/blog/[slug].js
import { useRouter } from 'next/router'

export default function BlogPost({ post }) {
  const router = useRouter()

  if (router.isFallback) {
    return <div>Loading...</div>
  }

  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
    </article>
  )
}

// Server-side rendering
export async function getServerSideProps({ params }) {
  const post = await fetchPost(params.slug)
  return { props: { post } }
}

// Or static generation
export async function getStaticProps({ params }) {
  const post = await fetchPost(params.slug)
  return { props: { post } }
}

export async function getStaticPaths() {
  const posts = await fetchAllPosts()
  return {
    paths: posts.map(post => ({ params: { slug: post.slug } })),
    fallback: false
  }
}

App Router Overview

The App Router is the future of Next.js routing. It introduces revolutionary concepts like React Server Components, streaming, and more granular control over loading and error states.

App Router Characteristics

  • Location: Routes live in the app/ directory
  • Folder-Based: Each folder can contain multiple special files (page.js, layout.js, loading.js, etc.)
  • Rendering: Server Components by default, with opt-in Client Components
  • Data Fetching: Uses native async/await directly in components
  • Layouts: First-class support with nested layouts
  • Streaming: Built-in support for progressive rendering

App Router Structure Example

app/
├── layout.js         # Root layout (wraps all pages)
├── page.js           # Home page (/)
├── loading.js        # Loading UI for home
├── error.js          # Error boundary for home
├── about/
│   └── page.js       # About page (/about)
└── blog/
    ├── layout.js     # Blog-specific layout
    ├── page.js       # Blog home (/blog)
    ├── loading.js    # Loading UI for blog
    └── [slug]/
        ├── page.js   # Blog post (/blog/:slug)
        └── loading.js # Loading UI for post

App Router Example

// app/blog/[slug]/page.js
// This is a Server Component by default
async function getPost(slug) {
  const res = await fetch(`https://api.example.com/posts/${slug}`, {
    cache: 'force-cache' // Equivalent to SSG
    // or cache: 'no-store' for SSR
  })
  return res.json()
}

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

  return (
    <article>
      <h1>{post.title}</h1>
      <p>{post.content}</p>
    </article>
  )
}

// Generate static params (like getStaticPaths)
export async function generateStaticParams() {
  const posts = await fetch('https://api.example.com/posts').then(r => r.json())
  return posts.map(post => ({ slug: post.slug }))
}

Key Architectural Differences

1. Server vs Client Components

App Router Innovation: The App Router introduces React Server Components, which run only on the server and never send JavaScript to the client. This dramatically reduces bundle sizes and improves performance.

Pages Router:

  • All components are Client Components by default
  • JavaScript for every component is sent to the browser
  • Server-side logic only in data fetching functions

App Router:

  • All components are Server Components by default
  • No JavaScript sent to the client unless needed
  • Client Components require the 'use client' directive
// App Router - Server Component (default)
export default function ServerComponent() {
  // This runs only on the server
  // No JavaScript sent to the client
  return <div>I'm a Server Component</div>
}

// App Router - Client Component
'use client'

import { useState } from 'react'

export default function ClientComponent() {
  const [count, setCount] = useState(0)
  // This runs in the browser
  return <button onClick={() => setCount(count + 1)}>{count}</button>
}

2. Data Fetching

Pages Router: Uses special functions that run at build time or request time

// Pages Router - Data fetching
export default function Page({ data }) {
  return <div>{data}</div>
}

// Runs on every request (SSR)
export async function getServerSideProps() {
  const data = await fetchData()
  return { props: { data } }
}

// Runs at build time (SSG)
export async function getStaticProps() {
  const data = await fetchData()
  return { props: { data } }
}

App Router: Uses async components and native fetch with caching options

// App Router - Data fetching
export default async function Page() {
  // Fetch directly in the component
  const data = await fetch('https://api.example.com/data', {
    cache: 'force-cache' // SSG behavior
    // cache: 'no-store' // SSR behavior
    // next: { revalidate: 3600 } // ISR behavior
  }).then(r => r.json())

  return <div>{data}</div>
}
Advantage: App Router's approach is more intuitive—you fetch data exactly where you need it, and the caching behavior is controlled through familiar fetch API options.

3. Layouts

Pages Router: Custom layouts through _app.js or per-page getLayout functions

// pages/_app.js - Global layout
export default function MyApp({ Component, pageProps }) {
  return (
    <Layout>
      <Component {...pageProps} />
    </Layout>
  )
}

// Per-page layout
Page.getLayout = function getLayout(page) {
  return (
    <DashboardLayout>
      {page}
    </DashboardLayout>
  )
}

App Router: First-class layout support with nesting

// app/layout.js - Root layout (required)
export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        <Header />
        {children}
        <Footer />
      </body>
    </html>
  )
}

// app/dashboard/layout.js - Nested layout
export default function DashboardLayout({ children }) {
  return (
    <div className="dashboard">
      <Sidebar />
      <main>{children}</main>
    </div>
  )
}
Benefit: In the App Router, layouts don't re-render when navigating between pages in the same layout. This preserves state and improves performance.

4. Loading States

Pages Router: Manual implementation

// Pages Router - Manual loading state
import { useRouter } from 'next/router'
import { useState, useEffect } from 'react'

export default function Page() {
  const router = useRouter()
  const [loading, setLoading] = useState(false)

  useEffect(() => {
    const handleStart = () => setLoading(true)
    const handleComplete = () => setLoading(false)

    router.events.on('routeChangeStart', handleStart)
    router.events.on('routeChangeComplete', handleComplete)

    return () => {
      router.events.off('routeChangeStart', handleStart)
      router.events.off('routeChangeComplete', handleComplete)
    }
  }, [router])

  if (loading) return <div>Loading...</div>
  return <div>Content</div>
}

App Router: Built-in loading.js file

// app/blog/loading.js - Automatic loading UI
export default function Loading() {
  return <div>Loading blog posts...</div>
}

// This automatically shows while page.js is loading

5. Error Handling

Pages Router: Custom error pages (_error.js) or try/catch in components

// pages/_error.js
function Error({ statusCode }) {
  return (
    <p>
      {statusCode
        ? `An error ${statusCode} occurred on server`
        : 'An error occurred on client'}
    </p>
  )
}

Error.getInitialProps = ({ res, err }) => {
  const statusCode = res ? res.statusCode : err ? err.statusCode : 404
  return { statusCode }
}

export default Error

App Router: Built-in error.js boundary

// app/blog/error.js - Error boundary
'use client' // Error components must be Client Components

export default function Error({ error, reset }) {
  return (
    <div>
      <h2>Something went wrong!</h2>
      <p>{error.message}</p>
      <button onClick={reset}>Try again</button>
    </div>
  )
}

Performance Implications

Bundle Size

Pages Router: Every component and its dependencies are bundled and sent to the client, even if they only display static content.

App Router: Server Components never send JavaScript to the client. Only interactive components marked with 'use client' are bundled.

Real-World Impact: In a typical application, the App Router can reduce JavaScript bundle sizes by 30-50% compared to the Pages Router, resulting in faster page loads and better performance, especially on slower devices and networks.

Streaming and Suspense

The App Router supports streaming HTML from the server, allowing faster Time to First Byte and progressive rendering:

// App Router - Streaming with Suspense
import { Suspense } from 'react'

export default function Page() {
  return (
    <div>
      <h1>My Blog</h1>
      {/* This renders immediately */}

      <Suspense fallback={<div>Loading posts...</div>}>
        {/* This streams in when ready */}
        <Posts />
      </Suspense>
    </div>
  )
}

async function Posts() {
  const posts = await fetchPosts() // Slow operation
  return (
    <ul>
      {posts.map(post => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  )
}

Migration Considerations

When to Use Pages Router

  • Maintaining existing applications built with Pages Router
  • Projects with specific requirements that aren't yet supported in App Router
  • Teams not ready to learn new patterns

When to Use App Router

  • All new projects (recommended by Vercel and the Next.js team)
  • Applications that need maximum performance
  • Projects that benefit from Server Components (reduced JavaScript)
  • Applications requiring advanced features like streaming and suspense

Gradual Migration Strategy

You can migrate gradually by having both routers in the same project:

project/
├── app/              # New routes using App Router
│   └── dashboard/
│       └── page.js   # /dashboard
└── pages/            # Legacy routes using Pages Router
    └── blog/
        └── [slug].js # /blog/:slug
Important: When both routers exist, the App Router takes precedence. If you have app/about/page.js and pages/about.js, only the App Router version will be accessible.

Comparison Table

Feature              | Pages Router        | App Router
---------------------|---------------------|--------------------
Directory            | pages/              | app/
Default Component    | Client Component    | Server Component
Data Fetching        | Special functions   | async/await in component
Layouts              | Manual via _app     | Built-in nested layouts
Loading States       | Manual              | loading.js file
Error Handling       | _error.js           | error.js boundary
Streaming            | Not supported       | Built-in support
Route Files          | Named files         | Special files in folders
Performance          | Good                | Better (less JS)
Learning Curve       | Easier              | Steeper initially
Stability            | Very stable         | Stable (13.4+)
Practice Exercises:
  1. Create the same page using both Pages Router and App Router to understand the differences
  2. Build a blog post page with loading and error states in the App Router
  3. Compare bundle sizes: Create identical pages with both routers and check the JavaScript bundle size in the Network tab
  4. Implement nested layouts in App Router (e.g., root layout → dashboard layout → specific page)
  5. Convert a Pages Router component to App Router, identifying which parts should be Server Components vs Client Components
  6. Create a streaming example using Suspense in the App Router
  7. Experiment with different caching strategies (force-cache, no-store, revalidate) in App Router data fetching

Summary

The App Router represents a significant evolution in Next.js architecture, bringing powerful new features while maintaining the simplicity that made Next.js popular. While the Pages Router remains stable and supported, the App Router is the recommended choice for new projects due to its superior performance, better developer experience with features like built-in loading and error states, and future-proof architecture with React Server Components.

Key takeaways:

  • App Router uses Server Components by default, reducing JavaScript sent to the client
  • Data fetching is more intuitive with async/await directly in components
  • Layouts, loading, and error states have first-class support
  • Both routers can coexist during migration
  • Choose App Router for new projects; maintain Pages Router for existing apps

In the next lesson, we'll dive deep into layouts and templates in the App Router, exploring how to create reusable UI structures and optimize your application's architecture.