Back to Articles
35 min read

The Definitive Guide to Next.js: App Router, Pages Router & Rendering Patterns

Navigate the evolution of Next.js with confidence. This comprehensive guide bridges the gap between the legacy Pages Router and the modern App Router, detailing every aspect of project structure, data fetching (SSR, SSG, ISR), and API development for scalable React applications.

Next.js Fundamentals

Next.js overview

Next.js is a React framework created by Vercel that provides server-side rendering (SSR), static site generation (SSG), API routes, and file-based routing out of the box, enabling developers to build production-ready, SEO-friendly web applications with minimal configuration.

┌─────────────────────────────────────────┐ │ Next.js │ ├─────────────────────────────────────────┤ │ React + SSR + SSG + Routing + API │ │ + Image Optimization + Code Splitting │ └─────────────────────────────────────────┘

Next.js vs Create React App

CRA is a client-side only React boilerplate with no built-in SSR or routing, while Next.js provides SSR/SSG, file-based routing, API routes, and better SEO capabilities; CRA requires additional libraries for features Next.js includes by default.

┌────────────────────┬────────────────────┐
│   Create React App │      Next.js       │
├────────────────────┼────────────────────┤
│ Client-side only   │ SSR + SSG + CSR    │
│ No routing         │ File-based routing │
│ Manual SEO         │ Built-in SEO       │
│ Single bundle      │ Auto code-split    │
└────────────────────┴────────────────────┘

Pages Router vs App Router

Pages Router (legacy) uses the pages/ directory with getServerSideProps/getStaticProps for data fetching, while App Router (new, recommended) uses the app/ directory with React Server Components by default, offering better performance, streaming, and a more intuitive layout system.

┌─────────────────────────────────────────┐
│  Pages Router        │  App Router      │
│  /pages              │  /app            │
│  getServerSideProps  │  Server Comp.    │
│  getStaticProps      │  async/await     │
│  _app.js, _document  │  layout.js       │
└─────────────────────────────────────────┘

Installation and setup

Create a new Next.js project using npx create-next-app@latest, which prompts for TypeScript, ESLint, Tailwind CSS, and App Router preferences, then generates a ready-to-run project structure.

# Create new project npx create-next-app@latest my-app # With specific options npx create-next-app@latest my-app --typescript --tailwind --app # Run development server cd my-app && npm run dev

Project structure

Next.js follows a convention-based structure where app/ or pages/ contains routes, public/ holds static assets, components/ for reusable UI, and configuration files sit at the root level.

my-app/
├── app/                 # App Router (routes, layouts)
│   ├── layout.js        # Root layout
│   ├── page.js          # Home page (/)
│   └── about/
│       └── page.js      # /about
├── components/          # Reusable components
├── public/              # Static files (images, fonts)
├── lib/                 # Utility functions
├── next.config.js       # Next.js configuration
├── package.json
└── tsconfig.json        # TypeScript config

next.config.js

The main configuration file for customizing Next.js behavior including redirects, rewrites, environment variables, image domains, webpack modifications, and experimental features.

/** @type {import('next').NextConfig} */ const nextConfig = { reactStrictMode: true, images: { domains: ['cdn.example.com'], }, redirects: async () => [ { source: '/old', destination: '/new', permanent: true } ], env: { CUSTOM_VAR: 'value', }, }; module.exports = nextConfig;

TypeScript support

Next.js has built-in TypeScript support; simply create a tsconfig.json file or use .ts/.tsx extensions, and Next.js auto-configures TypeScript with proper types for pages, API routes, and components.

// app/page.tsx interface User { id: number; name: string; } export default async function Page(): Promise<JSX.Element> { const users: User[] = await fetchUsers(); return <div>{users.map(u => <p key={u.id}>{u.name}</p>)}</div>; }

Environment variables

Next.js loads environment variables from .env* files automatically; prefix with NEXT_PUBLIC_ to expose to the browser, otherwise variables are only available server-side for security.

# .env.local DATABASE_URL=postgres://localhost/db # Server only NEXT_PUBLIC_API_URL=https://api.com # Available in browser # Access in code # Server: process.env.DATABASE_URL # Client: process.env.NEXT_PUBLIC_API_URL

.env.local, .env.production

.env.local is for local development secrets (gitignored), .env.development for dev defaults, .env.production for production builds; Next.js loads them in order with .env.local having highest priority.

Loading Priority (highest to lowest):
┌─────────────────────────────────┐
│ 1. .env.local                   │ ← Never committed
│ 2. .env.development / .env.prod │ ← Environment specific
│ 3. .env                         │ ← Default values
└─────────────────────────────────┘

# .env.local (gitignored)
SECRET_KEY=my-secret-dev-key

# .env.production (committed)
API_URL=https://prod.api.com

Pages Router (Legacy/Pages Directory)

File-based routing

In Pages Router, files inside the pages/ directory automatically become routes; the file path directly maps to the URL path, eliminating the need for manual route configuration.

pages/
├── index.js        →  /
├── about.js        →  /about
├── blog/
│   ├── index.js    →  /blog
│   └── post.js     →  /blog/post
└── contact.js      →  /contact

pages directory

The pages/ directory is the root for all routes in Pages Router, containing special files like _app.js (global wrapper), _document.js (HTML structure), and 404.js (error page).

pages/
├── _app.js         # Wraps all pages (global state, styles)
├── _document.js    # Custom HTML document structure
├── _error.js       # Custom error page
├── 404.js          # Custom 404 page
├── index.js        # Home page
└── api/            # API routes
    └── hello.js    # /api/hello

Index routes

index.js files represent the root of a directory; pages/index.js maps to /, and pages/blog/index.js maps to /blog, providing clean URLs without filename in the path.

// pages/index.js → / export default function Home() { return <h1>Welcome to Home</h1>; } // pages/blog/index.js → /blog export default function Blog() { return <h1>Blog Index</h1>; }

Nested routes

Create subdirectories inside pages/ to build nested URL structures; each level of folders adds a segment to the URL path.

pages/
├── dashboard/
│   ├── index.js           →  /dashboard
│   ├── settings/
│   │   ├── index.js       →  /dashboard/settings
│   │   └── profile.js     →  /dashboard/settings/profile
│   └── analytics.js       →  /dashboard/analytics

Dynamic routes ([id].js)

Square brackets denote dynamic segments that capture URL parameters; [id].js matches any value at that position and exposes it via router.query.

// pages/posts/[id].js → /posts/123, /posts/abc import { useRouter } from 'next/router'; export default function Post() { const router = useRouter(); const { id } = router.query; // "123" or "abc" return <h1>Post ID: {id}</h1>; }

Catch-all routes ([...slug].js)

Three dots inside brackets capture all subsequent path segments as an array; [...slug].js matches /a, /a/b, /a/b/c but not the base route.

// pages/docs/[...slug].js // /docs/a → slug = ['a'] // /docs/a/b/c → slug = ['a', 'b', 'c'] // /docs → 404 (not matched) export default function Docs() { const router = useRouter(); const { slug } = router.query; // ['a', 'b', 'c'] return <p>Path: {slug?.join('/')}</p>; }

Optional catch-all ([[...slug]].js)

Double brackets make the catch-all optional, matching the base route as well; [[...slug]].js matches /, /a, and /a/b/c.

// pages/shop/[[...slug]].js // /shop → slug = undefined // /shop/shoes → slug = ['shoes'] // /shop/shoes/nike → slug = ['shoes', 'nike'] export default function Shop() { const { slug } = useRouter().query; if (!slug) return <h1>All Products</h1>; return <h1>Category: {slug.join(' > ')}</h1>; }

The Link component enables client-side navigation with automatic prefetching; it wraps anchor tags and prevents full page reloads for internal routes.

import Link from 'next/link'; export default function Nav() { return ( <nav> <Link href="/">Home</Link> <Link href="/about">About</Link> <Link href="/posts/123">Post 123</Link> <Link href={{ pathname: '/search', query: { q: 'nextjs' } }}> Search </Link> <Link href="/dashboard" prefetch={false}>Dashboard</Link> </nav> ); }

useRouter hook

useRouter provides access to the router object for reading route information and performing programmatic navigation; available only in client components.

import { useRouter } from 'next/router'; export default function Page() { const router = useRouter(); console.log(router.pathname); // /posts/[id] console.log(router.query); // { id: '123' } console.log(router.asPath); // /posts/123?sort=asc console.log(router.isReady); // true when query is available return <div>Current path: {router.asPath}</div>; }

router.push, router.replace

router.push navigates to a new URL adding to history stack, while router.replace navigates without adding a history entry (like redirect behavior).

const router = useRouter(); // push - adds to history (back button works) router.push('/dashboard'); router.push('/posts/123'); router.push({ pathname: '/search', query: { q: 'next' } }); // replace - no history entry (back skips this page) router.replace('/login'); // With options router.push('/about', undefined, { scroll: false });

router.query

router.query is an object containing both dynamic route parameters and query string parameters; it's empty on first render until hydration completes (check router.isReady).

// URL: /posts/123?sort=date&order=desc // File: pages/posts/[id].js const router = useRouter(); // router.query = { id: '123', sort: 'date', order: 'desc' } useEffect(() => { if (router.isReady) { const { id, sort, order } = router.query; fetchPost(id, { sort, order }); } }, [router.isReady]);

router.pathname

router.pathname returns the route pattern as defined in pages/, showing bracket notation for dynamic segments rather than actual values.

// URL: /posts/123/comments/456 // File: pages/posts/[postId]/comments/[commentId].js const router = useRouter(); router.pathname; // "/posts/[postId]/comments/[commentId]" router.asPath; // "/posts/123/comments/456" router.query; // { postId: '123', commentId: '456' }

Programmatic navigation

Navigate imperatively using router.push(), router.replace(), or router.back(); useful after form submissions, authentication, or conditional redirects.

import { useRouter } from 'next/router'; export default function LoginForm() { const router = useRouter(); const handleSubmit = async (e) => { e.preventDefault(); const success = await login(credentials); if (success) { router.push('/dashboard'); // Navigate forward } else { router.replace('/login?error=1'); // Replace current } }; const goBack = () => router.back(); // Browser back return <form onSubmit={handleSubmit}>...</form>; }

Shallow routing

Shallow routing changes the URL without running data fetching methods (getServerSideProps/getStaticProps) again; useful for updating query params without refetching.

import { useRouter } from 'next/router'; export default function FilterPage() { const router = useRouter(); const updateFilter = (filter) => { router.push( { pathname: router.pathname, query: { ...router.query, filter } }, undefined, { shallow: true } // Won't re-run getServerSideProps ); }; // React to shallow changes useEffect(() => { console.log('Filter changed:', router.query.filter); }, [router.query.filter]); }

App Router (app Directory)

App directory structure

The app/ directory uses folder-based routing where each folder represents a route segment, and special files (page.js, layout.js, etc.) define UI and behavior for that route.

app/
├── layout.js          # Root layout (required)
├── page.js            # Home page (/)
├── loading.js         # Loading UI
├── error.js           # Error boundary
├── not-found.js       # 404 page
├── globals.css
├── about/
│   └── page.js        # /about
└── blog/
    ├── layout.js      # Blog layout
    ├── page.js        # /blog
    └── [slug]/
        └── page.js    # /blog/:slug

Server Components by default

In App Router, all components are React Server Components by default, running only on the server; they can directly fetch data, access backend resources, and reduce client-side JavaScript bundle size.

// app/users/page.js - Server Component (default) // Can use async/await directly, no "use client" needed async function getUsers() { const res = await fetch('https://api.example.com/users'); return res.json(); } export default async function UsersPage() { const users = await getUsers(); // Runs on server only return ( <ul> {users.map(user => <li key={user.id}>{user.name}</li>)} </ul> ); }

Client Components

Client Components run in the browser and are needed for interactivity (hooks, event handlers, browser APIs); mark them with 'use client' directive at the top of the file.

'use client'; // Must be first line import { useState } from 'react'; export default function Counter() { const [count, setCount] = useState(0); return ( <button onClick={() => setCount(count + 1)}> Count: {count} </button> ); }

'use client' directive

Place 'use client' at the top of a file to mark it and all its imports as Client Components; this creates a boundary between server and client component trees.

┌─────────────────────────────────────────┐
│           Server Component              │
│  ┌───────────────────────────────────┐  │
│  │ 'use client'                      │  │
│  │     Client Component              │  │
│  │   ┌─────────────────────────┐     │  │
│  │   │ Child (also client)     │     │  │
│  │   └─────────────────────────┘     │  │
│  └───────────────────────────────────┘  │
└─────────────────────────────────────────┘

// components/Modal.js
'use client';
// All imports below become client components
import { useState, useEffect } from 'react';

'use server' directive

'use server' marks functions as Server Actions that can be called from client components; they run exclusively on the server and are used for mutations, form handling, and database operations.

// app/actions.js 'use server'; export async function createPost(formData) { const title = formData.get('title'); await db.posts.create({ title }); revalidatePath('/posts'); } // app/page.js (Client Component) 'use client'; import { createPost } from './actions'; export default function Form() { return ( <form action={createPost}> <input name="title" /> <button type="submit">Create</button> </form> ); }

File conventions (page.js, layout.js, loading.js, error.js, not-found.js)

Special files in each route folder define specific behaviors: page.js (route UI), layout.js (shared wrapper), loading.js (Suspense fallback), error.js (error boundary), not-found.js (404 UI).

app/dashboard/
├── layout.js      # Wraps page + children routes
├── page.js        # UI for /dashboard
├── loading.js     # Shown while page loads
├── error.js       # Catches errors in this segment
├── not-found.js   # Custom 404 for this segment
└── template.js    # Like layout but remounts

┌─ layout.js ─────────────────────┐
│  ┌─ loading.js / error.js ───┐  │
│  │  ┌─ page.js ───────────┐  │  │
│  │  │    Content          │  │  │
│  │  └─────────────────────┘  │  │
│  └───────────────────────────┘  │
└─────────────────────────────────┘

Route groups ((folder))

Parentheses around folder names create route groups for organization without affecting the URL; useful for separating layouts or grouping related routes.

app/
├── (marketing)/          # Group - not in URL
│   ├── layout.js         # Marketing-specific layout
│   ├── about/
│   │   └── page.js       # /about (not /marketing/about)
│   └── contact/
│       └── page.js       # /contact
├── (shop)/
│   ├── layout.js         # Shop-specific layout  
│   └── products/
│       └── page.js       # /products
└── layout.js             # Root layout

Parallel routes (@folder)

@folder notation creates named slots that render multiple pages simultaneously in the same layout; useful for dashboards, modals, and split views.

app/
├── layout.js
├── page.js
├── @analytics/
│   └── page.js
└── @team/
    └── page.js

// app/layout.js
export default function Layout({ children, analytics, team }) {
  return (
    <div>
      <main>{children}</main>
      <aside>{analytics}</aside>
      <aside>{team}</aside>
    </div>
  );
}

Intercepting routes ((..)folder)

Intercepting routes display a different route in the current layout context (like modals); (.) matches same level, (..) matches one level up, (...) matches root.

app/
├── feed/
│   └── page.js                 # /feed
├── photo/
│   └── [id]/
│       └── page.js             # /photo/123 (full page)
└── @modal/
    └── (.)photo/
        └── [id]/
            └── page.js         # Intercepts /photo/123 as modal

# When clicking /photo/123 from /feed:
# → Shows modal (intercepted)
# Direct navigation to /photo/123:
# → Shows full page

Dynamic segments [folder]

Square brackets create dynamic route segments that capture URL values; the folder name becomes the parameter name accessible in page props.

// app/blog/[slug]/page.js → /blog/hello-world export default function BlogPost({ params }) { return <h1>Post: {params.slug}</h1>; // "hello-world" } // app/shop/[category]/[product]/page.js → /shop/shoes/nike-air export default function Product({ params }) { const { category, product } = params; return <h1>{product} in {category}</h1>; }

Catch-all segments [...folder]

Three dots capture all remaining segments as an array; required for the route to match (won't match the bare parent path).

// app/docs/[...slug]/page.js // /docs/a → params.slug = ['a'] // /docs/a/b/c → params.slug = ['a', 'b', 'c'] // /docs → 404 (not matched) export default function Docs({ params }) { const breadcrumb = params.slug.join(' > '); return <nav>{breadcrumb}</nav>; }

Optional catch-all [[...folder]]

Double brackets make catch-all optional, matching the parent path when no additional segments exist.

// app/shop/[[...categories]]/page.js // /shop → params.categories = undefined // /shop/mens → params.categories = ['mens'] // /shop/mens/shoes → params.categories = ['mens', 'shoes'] export default function Shop({ params }) { const categories = params.categories ?? []; if (categories.length === 0) { return <h1>All Products</h1>; } return <h1>Browsing: {categories.join(' / ')}</h1>; }

Route handlers (route.js)

route.js files define API endpoints using Web Request/Response APIs; they replace pages/api routes in App Router and support all HTTP methods.

// app/api/users/route.js import { NextResponse } from 'next/server'; export async function GET(request) { const users = await db.users.findMany(); return NextResponse.json(users); } export async function POST(request) { const body = await request.json(); const user = await db.users.create({ data: body }); return NextResponse.json(user, { status: 201 }); } // Dynamic: app/api/users/[id]/route.js export async function GET(request, { params }) { const user = await db.users.findUnique({ where: { id: params.id } }); return NextResponse.json(user); }

Middleware

Middleware runs before requests are completed, enabling redirects, rewrites, header modifications, and authentication checks; defined in middleware.js at project root.

// middleware.js (project root) import { NextResponse } from 'next/server'; export function middleware(request) { const token = request.cookies.get('token'); // Redirect unauthenticated users if (!token && request.nextUrl.pathname.startsWith('/dashboard')) { return NextResponse.redirect(new URL('/login', request.url)); } // Add custom header const response = NextResponse.next(); response.headers.set('x-custom-header', 'value'); return response; } export const config = { matcher: ['/dashboard/:path*', '/api/:path*'], };

Layouts and Pages

Root layout

The root layout (app/layout.js) is required and wraps all pages; it must include <html> and <body> tags and defines the global structure for your entire application.

// app/layout.js (required) import './globals.css'; export const metadata = { title: 'My App', description: 'Welcome to my app', }; export default function RootLayout({ children }) { return ( <html lang="en"> <body> <header>Global Header</header> <main>{children}</main> <footer>Global Footer</footer> </body> </html> ); }

Nested layouts

Each route segment can have its own layout.js that wraps child routes; nested layouts are composable and preserve state during navigation between child routes.

// app/dashboard/layout.js export default function DashboardLayout({ children }) { return ( <div className="dashboard"> <aside><DashboardNav /></aside> <section>{children}</section> </div> ); } // Structure: // RootLayout // └── DashboardLayout // └── page.js (or nested layouts)

Layout composition

Layouts automatically nest: the root layout wraps section layouts, which wrap subsection layouts; each layout receives children prop containing the nested content.

┌─ Root Layout ────────────────────────────┐
│  <html><body>                            │
│  ┌─ Dashboard Layout ─────────────────┐  │
│  │  <Sidebar>                         │  │
│  │  ┌─ Settings Layout ─────────────┐ │  │
│  │  │  <SettingsNav>                │ │  │
│  │  │  ┌─ page.js ────────────────┐ │ │  │
│  │  │  │  Actual page content     │ │ │  │
│  │  │  └──────────────────────────┘ │ │  │
│  │  └───────────────────────────────┘ │  │
│  └────────────────────────────────────┘  │
│  </body></html>                          │
└──────────────────────────────────────────┘

Template files (template.js)

Templates are like layouts but create a new instance on each navigation, resetting state and effects; use when you need fresh component instances per route.

// app/dashboard/template.js 'use client'; import { useEffect } from 'react'; export default function Template({ children }) { useEffect(() => { // Runs on EVERY navigation (unlike layout) logPageView(); }, []); return <div className="animate-fadeIn">{children}</div>; } // Layout vs Template: // Layout: State preserved, doesn't remount // Template: State reset, remounts every navigation

Page files (page.js)

page.js defines the unique UI for a route and is required to make a route publicly accessible; it receives params (dynamic segments) and searchParams (query string) as props.

// app/products/[id]/page.js export default async function ProductPage({ params, searchParams }) { const product = await getProduct(params.id); const showReviews = searchParams.reviews === 'true'; return ( <article> <h1>{product.name}</h1> <p>${product.price}</p> {showReviews && <Reviews productId={params.id} />} </article> ); } // URL: /products/123?reviews=true // params: { id: '123' } // searchParams: { reviews: 'true' }

Metadata

Metadata API allows defining SEO meta tags declaratively via static objects or dynamically via generateMetadata function; it supports title, description, Open Graph, and more.

// Static metadata export const metadata = { title: 'My Page', description: 'Page description', }; // Generates: // <title>My Page</title> // <meta name="description" content="Page description" />

Static metadata

Export a metadata object from layout.js or page.js to define static meta tags that don't depend on dynamic data.

// app/about/page.js export const metadata = { title: 'About Us', description: 'Learn more about our company', keywords: ['company', 'about', 'team'], authors: [{ name: 'John Doe' }], creator: 'ACME Inc', publisher: 'ACME Inc', robots: { index: true, follow: true, }, }; export default function AboutPage() { return <h1>About Us</h1>; }

Dynamic metadata

Use generateMetadata async function when metadata depends on dynamic data like route params or fetched content.

// app/blog/[slug]/page.js export async function generateMetadata({ params }) { const post = await getPost(params.slug); return { title: post.title, description: post.excerpt, openGraph: { title: post.title, images: [post.coverImage], }, }; } export default async function BlogPost({ params }) { const post = await getPost(params.slug); return <article>{post.content}</article>; }

generateMetadata function

generateMetadata receives params and parent (parent metadata), can fetch data, and returns a metadata object; Next.js automatically deduplicates fetch requests.

export async function generateMetadata({ params, searchParams }, parent) { const product = await fetch(`/api/products/${params.id}`).then(r => r.json()); // Access parent metadata const previousImages = (await parent).openGraph?.images || []; return { title: product.name, description: product.description, openGraph: { images: [product.image, ...previousImages], }, }; }

Metadata object

The metadata object supports numerous fields for SEO, social sharing, and browser behavior including title templates, canonical URLs, alternates, and verification tokens.

export const metadata = { title: { default: 'My Site', template: '%s | My Site', // Child pages: "About | My Site" }, description: 'Site description', metadataBase: new URL('https://mysite.com'), alternates: { canonical: '/', languages: { 'en-US': '/en', 'de-DE': '/de' }, }, verification: { google: 'google-verification-code', yandex: 'yandex-verification-code', }, category: 'technology', };

Open Graph metadata

Define Open Graph tags for rich social media previews on Facebook, LinkedIn, and other platforms using the openGraph property.

export const metadata = { openGraph: { title: 'My Article', description: 'Article description', url: 'https://mysite.com/articles/my-article', siteName: 'My Site', images: [ { url: 'https://mysite.com/og-image.jpg', width: 1200, height: 630, alt: 'Article preview image', }, ], locale: 'en_US', type: 'article', publishedTime: '2024-01-01T00:00:00.000Z', authors: ['John Doe'], }, };

Twitter Card metadata

Configure Twitter Card appearance for tweets containing your links using the twitter property.

export const metadata = { twitter: { card: 'summary_large_image', // or 'summary', 'player', 'app' title: 'My Page Title', description: 'Page description for Twitter', creator: '@username', site: '@sitename', images: ['https://mysite.com/twitter-image.jpg'], }, }; // Generates: // <meta name="twitter:card" content="summary_large_image" /> // <meta name="twitter:title" content="My Page Title" /> // <meta name="twitter:image" content="https://..." />

Viewport metadata

Control viewport behavior for responsive design; in Next.js 14+, use the separate viewport export instead of including in metadata.

// Next.js 14+ export const viewport = { width: 'device-width', initialScale: 1, maximumScale: 1, userScalable: false, themeColor: [ { media: '(prefers-color-scheme: light)', color: '#ffffff' }, { media: '(prefers-color-scheme: dark)', color: '#000000' }, ], colorScheme: 'dark light', }; // Generates: // <meta name="viewport" content="width=device-width, initial-scale=1" /> // <meta name="theme-color" content="#ffffff" />

Favicon and icons

Define favicons and app icons using the icons property or by placing files in the app/ directory with special names.

// Option 1: Metadata object export const metadata = { icons: { icon: '/favicon.ico', shortcut: '/shortcut-icon.png', apple: '/apple-icon.png', other: [ { rel: 'icon', url: '/icon-32.png', sizes: '32x32', type: 'image/png' }, { rel: 'icon', url: '/icon-16.png', sizes: '16x16', type: 'image/png' }, ], }, }; // Option 2: File conventions in app/ // app/ // ├── favicon.ico → <link rel="icon" /> // ├── icon.png → <link rel="icon" /> // ├── apple-icon.png → <link rel="apple-touch-icon" /> // └── opengraph-image.png → og:image meta tag

Data Fetching (Pages Router)

getStaticProps

A function exported from a page that runs at build time to fetch data and pass it as props, enabling Static Site Generation (SSG). The code runs only on the server and is never sent to the browser.

export async function getStaticProps() { const res = await fetch('https://api.example.com/posts'); const posts = await res.json(); return { props: { posts } }; // passed to page component }

getStaticPaths

Required for dynamic routes with getStaticProps, this function tells Next.js which paths to pre-render at build time by returning an array of possible route parameters.

export async function getStaticPaths() { const posts = await fetch('https://api.example.com/posts').then(r => r.json()); const paths = posts.map(post => ({ params: { id: post.id.toString() } })); return { paths, fallback: 'blocking' }; // false | true | 'blocking' }

getServerSideProps

Runs on every request to fetch data server-side, enabling Server-Side Rendering (SSR). Use when you need request-time data like cookies, headers, or frequently changing content.

export async function getServerSideProps(context) { const { req, res, params, query } = context; const data = await fetch(`https://api.example.com/user/${query.id}`); return { props: { data } }; // or { notFound: true } or { redirect: {...} } }

Incremental Static Regeneration (ISR)

Allows static pages to be updated after build time without rebuilding the entire site, combining the benefits of static generation with fresh content by regenerating pages in the background.

┌─────────────────────────────────────────────────────┐
│  Build Time: Page Generated (cached)                │
├─────────────────────────────────────────────────────┤
│  Request 1-N: Serve cached page                     │
│  After revalidate time: Trigger background rebuild  │
│  Next request: Serve fresh page                     │
└─────────────────────────────────────────────────────┘

revalidate option

A number (in seconds) added to getStaticProps return object that determines how often a page should be regenerated; after this time passes, the next request triggers a background regeneration.

export async function getStaticProps() { const data = await fetchData(); return { props: { data }, revalidate: 60, // Regenerate page every 60 seconds max }; }

On-demand revalidation

Allows programmatic purging of cached pages via API routes instead of waiting for the revalidate timer, useful for CMS webhooks or immediate content updates.

// pages/api/revalidate.js export default async function handler(req, res) { if (req.query.secret !== process.env.REVALIDATE_TOKEN) { return res.status(401).json({ message: 'Invalid token' }); } await res.revalidate('/posts/' + req.query.slug); return res.json({ revalidated: true }); }

Client-side data fetching

Fetching data in the browser using useEffect or data-fetching libraries, suitable for user-specific content, dashboards, or data that doesn't need SEO and changes frequently.

function Profile() { const [user, setUser] = useState(null); useEffect(() => { fetch('/api/user').then(r => r.json()).then(setUser); }, []); if (!user) return <div>Loading...</div>; return <div>Hello, {user.name}</div>; }

SWR with Next.js

Vercel's data-fetching React hook library that provides caching, revalidation, focus tracking, and interval refetching out of the box with a simple API.

import useSWR from 'swr'; const fetcher = url => fetch(url).then(r => r.json()); function Profile() { const { data, error, isLoading } = useSWR('/api/user', fetcher); if (error) return <div>Failed to load</div>; if (isLoading) return <div>Loading...</div>; return <div>Hello, {data.name}</div>; }

React Query with Next.js

TanStack Query provides powerful data synchronization with features like background refetching, pagination, infinite scroll, and mutations with optimistic updates.

import { useQuery } from '@tanstack/react-query'; function Posts() { const { data, isLoading, error } = useQuery({ queryKey: ['posts'], queryFn: () => fetch('/api/posts').then(r => r.json()), staleTime: 60000, // Consider fresh for 1 minute }); return isLoading ? <p>Loading...</p> : <PostList posts={data} />; }

Data Fetching (App Router)

Server Components data fetching

In App Router, components are Server Components by default and can directly use async/await to fetch data without special functions, simplifying data fetching patterns significantly.

// app/posts/page.js - This IS a Server Component async function PostsPage() { const posts = await fetch('https://api.example.com/posts').then(r => r.json()); return <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>; } export default PostsPage;

fetch with caching

Next.js extends the native fetch API to automatically cache responses by default, enabling request deduplication and static generation without additional configuration.

// Cached by default (similar to SSG) const data = await fetch('https://api.example.com/data'); // Request deduplication: same URL fetched in multiple components = 1 request // ┌─────────────┐ ┌─────────────┐ // │ Component A │ ──┐ │ │ // │ fetch(url) │ ├─│ 1 Request │ // │ Component B │ ──┘ │ │ // │ fetch(url) │ └─────────────┘

fetch cache options

The cache option controls how fetch requests interact with Next.js Data Cache: 'force-cache' (default), 'no-store' for dynamic data, controlling static vs dynamic rendering.

// Static: cached indefinitely (default) fetch(url, { cache: 'force-cache' }); // Dynamic: fresh data every request fetch(url, { cache: 'no-store' }); // Also via Next.js extension: fetch(url, { next: { revalidate: 3600 } }); // ISR-style

revalidate option (App Router)

The next.revalidate option in fetch specifies the cache lifetime in seconds, or can be set at the layout/page level via the revalidate segment config export.

// Per-fetch revalidation const data = await fetch(url, { next: { revalidate: 60 } }); // Segment-level config (applies to entire route) // app/posts/page.js export const revalidate = 60; // seconds

Cache tags

Tags allow grouping cached data logically so multiple cache entries can be invalidated together, providing fine-grained control over cache invalidation.

// Tag your fetches const posts = await fetch(url, { next: { tags: ['posts'] } }); const post = await fetch(`${url}/${id}`, { next: { tags: ['posts', `post-${id}`] } }); // Cache structure: // ┌────────────────────────────────────┐ // │ Tag: 'posts' → [all posts, post-1, post-2...] // │ Tag: 'post-1' → [single post data] // └────────────────────────────────────┘

revalidateTag

A server action or route handler function that invalidates all cache entries associated with a specific tag, enabling on-demand revalidation for App Router.

// app/actions.js 'use server'; import { revalidateTag } from 'next/cache'; export async function updatePost(id, data) { await db.posts.update(id, data); revalidateTag('posts'); // Invalidate all posts revalidateTag(`post-${id}`); // Invalidate specific post }

revalidatePath

Purges cached data for a specific URL path, useful when you need to invalidate a page without using tags or when the path itself is the cache key.

'use server'; import { revalidatePath } from 'next/cache'; export async function publishPost() { await db.posts.publish(); revalidatePath('/posts'); // Revalidate specific path revalidatePath('/posts/[slug]', 'page'); // Dynamic segment revalidatePath('/posts', 'layout'); // Layout and all children }

Streaming

Progressively sends HTML from server to client as it becomes ready, improving Time To First Byte (TTFB) and allowing users to see content before the entire page loads.

Traditional SSR:          Streaming:
─────────────────────    ─────────────────────
[====WAIT====][Render]   [Shell][...data...][...more...]
              ↓                  ↓      ↓         ↓
           All at once       Progressive chunks

Suspense with Server Components

React's Suspense boundaries wrap async Server Components to show fallback UI while data loads, enabling granular loading states and streaming of content.

import { Suspense } from 'react'; async function SlowComponent() { const data = await slowFetch(); // 3 seconds return <div>{data}</div>; } export default function Page() { return ( <div> <h1>Dashboard</h1> <Suspense fallback={<p>Loading data...</p>}> <SlowComponent /> </Suspense> </div> ); }

Loading.js files

A special file that automatically creates a Suspense boundary for a route segment, showing its content while the page or layout is loading.

app/
├── dashboard/
│   ├── loading.js   ← Shows while page.js loads
│   └── page.js      ← Async data fetching here
// app/dashboard/loading.js export default function Loading() { return <div className="spinner">Loading dashboard...</div>; }

Parallel data fetching

Initiating multiple data requests simultaneously using Promise.all to avoid waterfalls and reduce total loading time when requests are independent.

async function Dashboard() { // ❌ Sequential (waterfall): ~6 seconds // const user = await getUser(); // 3s // const posts = await getPosts(); // 3s // ✅ Parallel: ~3 seconds const [user, posts] = await Promise.all([ getUser(), // 3s ─┐ getPosts(), // 3s ─┴─ Run together ]); return <div>{user.name}: {posts.length} posts</div>; }

Sequential data fetching

Fetching data in sequence when later requests depend on earlier results, creating intentional waterfalls where each request awaits the previous one.

async function UserPosts({ userId }) { // Sequential: posts depend on user const user = await getUser(userId); const posts = await getPostsByAuthor(user.authorId); // needs user.authorId // Timeline: [getUser]──>[getPostsByAuthor] return <PostList posts={posts} />; }

Dynamic rendering

Routes are rendered at request time when using dynamic functions (cookies(), headers(), searchParams) or uncached data fetches, ensuring fresh content.

import { cookies, headers } from 'next/headers'; async function Page({ searchParams }) { // Any of these forces dynamic rendering: const cookieStore = cookies(); // Dynamic const headersList = headers(); // Dynamic const query = searchParams; // Dynamic const data = await fetch(url, { cache: 'no-store' }); // Dynamic return <div>Rendered at: {new Date().toISOString()}</div>; }

Static rendering

Routes are rendered at build time (or during revalidation) and cached, providing the fastest response times; this is the default when no dynamic functions are used.

// This page is statically rendered async function BlogPost({ params }) { const post = await fetch(`api/posts/${params.id}`); // Cached by default return <article>{post.content}</article>; } // Force static even with dynamic segments: export async function generateStaticParams() { const posts = await getPosts(); return posts.map(post => ({ id: post.id })); }

Partial prerendering (experimental)

Combines static shell rendering with dynamic content streaming, allowing a page's static parts to be served instantly from CDN while dynamic holes stream in.

// next.config.js module.exports = { experimental: { ppr: true } }; // Page with PPR: // ┌──────────────────────────────────┐ // │ ████ Static Shell ████ │ ← Instant from CDN // │ ┌────────────────────────────┐ │ // │ │ <Suspense> │ │ // │ │ Dynamic Content (stream) │ │ ← Streams in later // │ └────────────────────────────┘ │ // └──────────────────────────────────┘

Rendering Strategies

Static Site Generation (SSG)

Pages are pre-rendered at build time into static HTML files, served from CDN for maximum performance; ideal for content that doesn't change frequently.

Build Time                    Request Time
──────────────────────────    ──────────────────────────
[Fetch Data] → [Generate HTML] → [CDN Cache] → [User]
                                      ↓
                              Instant response!

Server-Side Rendering (SSR)

Pages are rendered on the server for each request, providing fresh data and dynamic content at the cost of slower Time To First Byte compared to static pages.

Each Request:
[User] → [Server] → [Fetch Data] → [Render HTML] → [User]
                         └── Happens every time

Incremental Static Regeneration (ISR)

Combines SSG with background regeneration, serving stale pages instantly while revalidating in the background after a specified time interval.

┌─────────────────────────────────────────────────────────┐
│ t=0:  Build → Cache page (revalidate: 60)               │
│ t=30: Request → Serve cached (still fresh)              │
│ t=70: Request → Serve stale + trigger background regen  │
│ t=71: Request → Serve fresh page                        │
└─────────────────────────────────────────────────────────┘

Client-Side Rendering (CSR)

Page shell loads first, then JavaScript fetches data and renders content in the browser; useful for private dashboards or highly interactive apps.

'use client'; // Mark as Client Component function Dashboard() { const [data, setData] = useState(null); useEffect(() => { fetchData().then(setData); }, []); // Timeline: [Load JS] → [Execute] → [Fetch] → [Render] return data ? <Chart data={data} /> : <Skeleton />; }

React Server Components (RSC)

Components that render only on the server, sending zero JavaScript to the client; can directly access databases, filesystems, and use async/await at the component level.

// Server Component (default in App Router) async function ProductList() { const products = await db.products.findMany(); // Direct DB access! return products.map(p => <Product key={p.id} {...p} />); } // Benefits: // ├── Zero client JS for this component // ├── Direct backend access // └── Smaller bundles

Hybrid rendering

Mixing different rendering strategies within the same application based on each route's needs—some pages static, some SSR, some client-rendered.

app/
├── page.js           → SSG (marketing)
├── blog/[slug]/
│   └── page.js       → ISR (content updates)
├── dashboard/
│   └── page.js       → SSR (user-specific)
└── settings/
    └── page.js       → CSR (interactive)

Dynamic rendering (strategy)

A rendering mode where the HTML is generated at request time, automatically triggered by dynamic functions or opted into explicitly via segment config.

// Force dynamic rendering export const dynamic = 'force-dynamic'; // Or automatically triggered by: // - cookies(), headers() // - searchParams prop // - fetch with { cache: 'no-store' }

Static rendering (strategy)

Default rendering mode where pages are pre-rendered and cached, determined at build time or during revalidation; the fastest delivery method.

// Force static rendering (error if dynamic functions used) export const dynamic = 'force-static'; // Segment config options: export const revalidate = 3600; // ISR every hour export const revalidate = false; // Never revalidate (true SSG)

Edge runtime

A lightweight runtime for running code at CDN edge locations with lower latency, supporting a subset of Node.js APIs optimized for fast cold starts.

// app/api/geo/route.js export const runtime = 'edge'; export async function GET(request) { const country = request.geo?.country || 'Unknown'; return new Response(`Hello from ${country}!`); } // Edge: Runs in 30+ global locations, <1ms cold start

Node.js runtime

The default full-featured runtime with complete Node.js API access, suitable for complex operations, database connections, and file system access.

// app/api/heavy/route.js export const runtime = 'nodejs'; // Default export async function POST(request) { const fs = require('fs'); const result = await heavyComputation(); return Response.json(result); } // Node.js: Full API access, runs in single region

API Routes (Pages Router)

pages/api directory

The special directory where API endpoints live in Pages Router; each file becomes an endpoint at /api/* path, running only on the server.

pages/
└── api/
    ├── users.js         → /api/users
    ├── posts/
    │   ├── index.js     → /api/posts
    │   └── [id].js      → /api/posts/:id
    └── auth/
        └── [...auth].js → /api/auth/*

API route files

JavaScript/TypeScript files exporting a default handler function that receives request and response objects, similar to Express.js middleware.

// pages/api/hello.js export default function handler(req, res) { res.status(200).json({ message: 'Hello World' }); } // TypeScript version: // import { NextApiRequest, NextApiResponse } from 'next'; // export default function handler(req: NextApiRequest, res: NextApiResponse) {...}

Request and response objects

Extended Node.js IncomingMessage and ServerResponse objects with additional helpers for common operations like parsing body and sending JSON.

export default function handler(req, res) { // Request helpers req.cookies; // Parsed cookies req.query; // Query string params req.body; // Parsed body (if JSON/form) // Response helpers res.status(201); res.json({ data: 'value' }); res.redirect('/other'); res.send('text'); }

HTTP methods

The request method is available via req.method, allowing a single handler to process different HTTP verbs with conditional logic.

export default function handler(req, res) { switch (req.method) { case 'GET': return res.json(getData()); case 'POST': return res.status(201).json(createItem(req.body)); case 'DELETE': return res.status(204).end(); default: res.setHeader('Allow', ['GET', 'POST', 'DELETE']); return res.status(405).end(`Method ${req.method} Not Allowed`); } }

Query parameters

URL query strings are automatically parsed and available on req.query, including both static and dynamic route parameters.

// /api/search?q=next&page=2 export default function handler(req, res) { const { q, page } = req.query; // q = 'next', page = '2' (strings!) res.json({ query: q, page: parseInt(page) }); }

Request body parsing

JSON and form data bodies are automatically parsed when the appropriate Content-Type header is set; disable with config for raw body access.

export default function handler(req, res) { // Automatically parsed for application/json const { name, email } = req.body; res.json({ received: { name, email } }); } // Disable for raw body (webhooks, file uploads) export const config = { api: { bodyParser: false } };

API middlewares

Reusable functions that wrap handlers to add authentication, logging, or other cross-cutting concerns before the main handler executes.

// middleware/auth.js export function withAuth(handler) { return async (req, res) => { const token = req.headers.authorization; if (!verifyToken(token)) { return res.status(401).json({ error: 'Unauthorized' }); } return handler(req, res); }; } // pages/api/protected.js export default withAuth(function handler(req, res) { res.json({ secret: 'data' }); });

Dynamic API routes

API routes with bracket notation for path parameters, where the parameter value is available in req.query alongside query string parameters.

// pages/api/users/[id].js → /api/users/123 export default function handler(req, res) { const { id } = req.query; // '123' res.json({ userId: id }); } // pages/api/posts/[...slug].js → /api/posts/2024/01/hello // req.query.slug = ['2024', '01', 'hello']

Catch-all API routes

Routes using [...param] syntax to match any depth of path segments, useful for proxy routes or hierarchical resources.

// pages/api/proxy/[...path].js export default async function handler(req, res) { const path = req.query.path.join('/'); // 'a/b/c' const response = await fetch(`https://external.api/${path}`); res.json(await response.json()); } // /api/proxy/users/123/posts → path = ['users', '123', 'posts']

API route helpers

Built-in utilities and config options for customizing API behavior including response methods, CORS handling, and body size limits.

export default function handler(req, res) { res.status(200); // Set status code res.json({ data: 'ok' }); // Send JSON res.send('text'); // Send text res.redirect(301, '/new'); // Redirect res.revalidate('/path'); // ISR revalidation } export const config = { api: { bodyParser: { sizeLimit: '1mb' }, responseLimit: false, }, };

Route Handlers (App Router)

app/api directory structure

Route handlers in App Router use route.js files within the app directory; the folder path defines the URL route.

app/
├── api/
│   ├── route.js           → /api
│   ├── users/
│   │   ├── route.js       → /api/users
│   │   └── [id]/
│   │       └── route.js   → /api/users/:id
│   └── posts/
│       └── [...slug]/
│           └── route.js   → /api/posts/*

route.js files

Special files that export named functions for HTTP methods, replacing the single default handler pattern with explicit method exports.

// app/api/items/route.js export async function GET(request) { return Response.json({ items: [] }); } export async function POST(request) { const body = await request.json(); return Response.json({ created: body }, { status: 201 }); }

GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS

Supported HTTP methods are exported as named async functions; unsupported methods automatically return 405 Method Not Allowed.

// app/api/resource/route.js export async function GET() { return Response.json({ data: 'read' }); } export async function POST() { return Response.json({ data: 'create' }); } export async function PUT() { return Response.json({ data: 'replace' }); } export async function PATCH() { return Response.json({ data: 'update' }); } export async function DELETE() { return new Response(null, { status: 204 }); } export async function HEAD() { return new Response(null); } export async function OPTIONS() { return new Response(null, { headers: { Allow: 'GET, POST' } }); }

Request and Response objects

Route handlers use standard Web API Request and Response objects, providing cross-platform compatibility with edge runtimes.

export async function POST(request) { // Request (Web API) const url = new URL(request.url); const body = await request.json(); const headers = request.headers.get('Authorization'); // Response (Web API) return new Response(JSON.stringify({ ok: true }), { status: 200, headers: { 'Content-Type': 'application/json' }, }); }

NextRequest and NextResponse

Extended Request/Response classes from Next.js adding convenient helpers for cookies, redirects, and URL manipulation.

import { NextRequest, NextResponse } from 'next/server'; export async function GET(request: NextRequest) { // NextRequest extras const searchParams = request.nextUrl.searchParams; const cookie = request.cookies.get('session'); // NextResponse extras const response = NextResponse.json({ data: 'ok' }); response.cookies.set('visited', 'true'); return response; // Or redirect: // return NextResponse.redirect(new URL('/login', request.url)); }

Dynamic route handlers

Route handlers with path parameters using bracket notation; parameters are accessed via the second argument's params property.

// app/api/users/[id]/route.js export async function GET(request, { params }) { const { id } = params; // For /api/users/123, id = '123' const user = await getUser(id); if (!user) { return Response.json({ error: 'Not found' }, { status: 404 }); } return Response.json(user); }

Route handler context

The second parameter contains route context including params for dynamic segments; destructure to access path parameters.

// app/api/posts/[category]/[id]/route.js export async function GET( request, { params } // Context object ) { const { category, id } = params; // /api/posts/tech/42 → category='tech', id='42' return Response.json({ category, id }); }

Streaming responses

Route handlers can stream responses using Web Streams API, enabling real-time data, Server-Sent Events, or chunked responses.

export async function GET() { const encoder = new TextEncoder(); const stream = new ReadableStream({ async start(controller) { for (let i = 0; i < 5; i++) { controller.enqueue(encoder.encode(`data: Message ${i}\n\n`)); await new Promise(r => setTimeout(r, 1000)); } controller.close(); }, }); return new Response(stream, { headers: { 'Content-Type': 'text/event-stream' }, }); }

Edge runtime for API routes

Deploy route handlers to edge locations for lower latency by adding runtime config; supports limited Node.js APIs but offers faster cold starts.

// app/api/edge-function/route.js export const runtime = 'edge'; export async function GET(request) { const { country, city } = request.geo ?? {}; return Response.json({ location: `${city}, ${country}`, timestamp: Date.now(), }); }

CORS handling

Cross-Origin Resource Sharing headers must be set manually in route handlers; create a helper for reusable CORS configuration.

const corsHeaders = { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, Authorization', }; export async function OPTIONS() { return new Response(null, { headers: corsHeaders }); } export async function GET() { return Response.json({ data: 'ok' }, { headers: corsHeaders }); }

Webhooks

Route handlers are ideal for receiving webhooks from external services; verify signatures and process payloads securely.

// app/api/webhooks/stripe/route.js import { headers } from 'next/headers'; import Stripe from 'stripe'; export async function POST(request) { const body = await request.text(); const signature = headers().get('stripe-signature'); try { const event = stripe.webhooks.constructEvent( body, signature, process.env.STRIPE_WEBHOOK_SECRET ); switch (event.type) { case 'payment_intent.succeeded': await handlePayment(event.data.object); break; } return Response.json({ received: true }); } catch (err) { return Response.json({ error: err.message }, { status: 400 }); } }