Introduction
Next.js 13+ introduced the App Router — a complete reimagining of how Next.js applications are structured, built on React Server Components (RSC). It is not a minor update; it is a fundamentally different mental model. This guide cuts through the confusion and explains what actually matters for building production applications.
App Router vs Pages Router
The new app/ directory coexists with the old pages/ directory. You can migrate incrementally. The core differences:
| Feature | Pages Router | App Router |
|---|---|---|
| Default component | Client Component | Server Component |
| Data fetching | getServerSideProps, getStaticProps | async/await in components |
| Layouts | Custom _app.tsx | layout.tsx files |
| Loading states | Manual | loading.tsx + Suspense |
| Error handling | _error.tsx | error.tsx per segment |
Server Components vs Client Components
By default, every component in app/ is a Server Component. Server Components run only on the server — they can access databases, filesystem, and secrets directly, but cannot use browser APIs, state, or event handlers.
// app/products/page.tsx — Server Component (default)
// No 'use client' directive = runs on server only
async function ProductsPage() {
// Direct database access — no useEffect, no API call needed
const products = await db.query("SELECT * FROM products LIMIT 20")
return (
<div>
<h1>Products</h1>
{products.map(p => (
<ProductCard key={p.id} product={p} />
))}
</div>
)
}
// components/AddToCart.tsx — Client Component
'use client' // This directive marks it as a Client Component
import { useState } from 'react'
export function AddToCart({ productId }: { productId: string }) {
const [loading, setLoading] = useState(false)
async function handleClick() {
setLoading(true)
await fetch('/api/cart', {
method: 'POST',
body: JSON.stringify({ productId })
})
setLoading(false)
}
return (
<button onClick={handleClick} disabled={loading}>
{loading ? 'Adding...' : 'Add to Cart'}
</button>
)
}
The key rule: Server Components can import and render Client Components, but Client Components cannot import Server Components (only receive them as props/children). Keep 'use client' at the leaves of your component tree.
File Conventions in app/
app/
├── layout.tsx # Root layout — wraps all pages, never unmounts
├── page.tsx # Homepage route (/)
├── loading.tsx # Loading UI for this segment (Suspense wrapper)
├── error.tsx # Error boundary for this segment
├── not-found.tsx # 404 for this segment
├── products/
│ ├── layout.tsx # Products layout
│ ├── page.tsx # /products
│ └── [id]/
│ ├── page.tsx # /products/123
│ └── loading.tsx # Loading for individual product
└── api/
└── cart/
└── route.ts # API route: POST /api/cart
Layouts — Persistent UI
// app/layout.tsx — Root layout
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
const inter = Inter({ subsets: ['latin'] })
export const metadata: Metadata = {
title: { template: '%s | My App', default: 'My App' },
description: 'Product description'
}
export default function RootLayout({
children
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body className={inter.className}>
<nav>...navigation...</nav>
<main>{children}</main>
<footer>...footer...</footer>
</body>
</html>
)
}
Streaming with Suspense
Streaming sends HTML to the browser in chunks as it becomes available. Components wrapped in Suspense stream their content independently — the shell renders immediately, slow components stream in when ready.
// app/dashboard/page.tsx
import { Suspense } from 'react'
import { RevenueChart } from './RevenueChart'
import { RecentOrders } from './RecentOrders'
export default function DashboardPage() {
return (
<div>
<h1>Dashboard</h1>
{/* Streams when revenue data resolves */}
<Suspense fallback={<div className="skeleton h-72" />}>
<RevenueChart />
</Suspense>
{/* Streams independently — not blocked by chart */}
<Suspense fallback={<div className="skeleton h-96" />}>
<RecentOrders />
</Suspense>
</div>
)
}
// RevenueChart.tsx — Server Component with slow query
async function RevenueChart() {
const data = await fetchRevenueData() // slow DB query
return <LineChart data={data} />
}
Data Fetching and Caching
The App Router extends the native fetch API with caching controls:
// Static — cached indefinitely, like getStaticProps
const data = await fetch('https://api.example.com/config', {
cache: 'force-cache'
})
// Dynamic — fresh every request, like getServerSideProps
const data = await fetch('https://api.example.com/live-prices', {
cache: 'no-store'
})
// ISR — revalidate every hour
const posts = await fetch('https://api.example.com/posts', {
next: { revalidate: 3600 }
})
// Tag-based on-demand revalidation
const posts = await fetch('https://api.example.com/posts', {
next: { tags: ['posts'] }
})
// app/api/revalidate/route.ts — webhook to bust cache
import { revalidateTag } from 'next/cache'
import { NextRequest } from 'next/server'
export async function POST(req: NextRequest) {
const { tag } = await req.json()
revalidateTag(tag) // bust all fetch() calls with this tag
return Response.json({ revalidated: true })
}
Server Actions — Mutations Without API Routes
// app/contact/page.tsx
import { revalidatePath } from 'next/cache'
async function submitContact(formData: FormData) {
'use server'
const name = formData.get('name') as string
const email = formData.get('email') as string
await db.insert('contacts', { name, email })
revalidatePath('/contacts')
}
export default function ContactPage() {
return (
<form action={submitContact}>
<input name="name" placeholder="Name" required />
<input name="email" type="email" placeholder="Email" required />
<button type="submit">Submit</button>
</form>
)
}
Route Handlers — API Endpoints
// app/api/users/route.ts
import { NextRequest, NextResponse } from 'next/server'
export async function GET(req: NextRequest) {
const users = await db.query("SELECT id, name, email FROM users")
return NextResponse.json(users)
}
export async function POST(req: NextRequest) {
const body = await req.json()
const user = await db.insert('users', body)
return NextResponse.json(user, { status: 201 })
}
// app/api/users/[id]/route.ts
export async function GET(
req: NextRequest,
{ params }: { params: { id: string } }
) {
const user = await db.findById(params.id)
if (!user) return NextResponse.json({ error: 'Not found' }, { status: 404 })
return NextResponse.json(user)
}
Common Pitfalls
- Over-using 'use client' — push it to the leaves. A page that needs one interactive button does not need to be a Client Component; only that button does.
- Forgetting Suspense — async Server Components without Suspense boundaries block the entire page
- Non-serializable props — you cannot pass functions or class instances from Server to Client Components as props
- Cache confusion — Next.js has four caching layers; understand each one before debugging slow pages
- Missing 'use server' in actions — without the directive, the function runs on the client
DevKits Tools for Next.js Development
Build your Next.js applications faster with these DevKits tools:
- JSON Formatter — validate API Route responses and Server Action payloads
- UUID Generator — generate IDs for your database records during development
Summary
The App Router's mental model in one sentence: everything is a Server Component by default; add 'use client' only when you need state, effects, or browser APIs. The benefits — zero client JavaScript for data-heavy pages, direct database access, automatic streaming — are real but require understanding the new paradigm first.