Next.js App Router — The Complete Guide for 2026
Advertisement
Introduction
Why This Matters
The Next.js App Router, stable since Next.js 13.4, is now the default and recommended architecture for every new Next.js project. It fundamentally changes how pages are structured, how data is fetched, and how layouts are composed. If you are still using getServerSideProps and the pages/ directory, this guide will get you up to speed on what changed and why.
App Router vs Pages Router
| Feature | Pages Router | App Router |
|---|---|---|
| Default component type | Client Component | Server Component |
| Data fetching | getServerSideProps, getStaticProps | async/await directly in components |
| Nested layouts | Manual | Built-in via layout.tsx |
| Streaming | Not supported | Full streaming with Suspense |
| Loading states | Manual | Built-in loading.tsx |
| Error boundaries | _error.js | Built-in error.tsx |
| API routes | pages/api/ | app/api/.../route.ts |
File Structure and Conventions
The App Router uses the app/ directory. Every folder represents a URL segment. Special files control the UI for each segment:
app/
├── layout.tsx ← Root layout (required, wraps all pages)
├── page.tsx ← Home page /
├── loading.tsx ← Loading UI for /
├── error.tsx ← Error UI for /
├── not-found.tsx ← 404 page
├── blog/
│ ├── page.tsx ← /blog
│ └── [slug]/
│ ├── page.tsx ← /blog/[slug]
│ └── loading.tsx ← Loading for /blog/[slug]
├── dashboard/
│ ├── layout.tsx ← Nested layout (persists during navigation)
│ ├── page.tsx ← /dashboard
│ └── settings/
│ └── page.tsx ← /dashboard/settings
└── api/
└── users/
└── route.ts ← API Route HandlerRoot Layout
Every App Router project requires a root layout.tsx. It renders the html and body tags and wraps every page:
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import './globals.css'
const inter = Inter({ subsets: ['latin'] })
export const metadata: Metadata = {
title: { template: '%s | My App', default: 'My App' },
description: 'Built with the Next.js App Router',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body className={inter.className}>
<header>Navigation</header>
<main>{children}</main>
<footer>Footer</footer>
</body>
</html>
)
}Data Fetching — No More getServerSideProps
In the App Router, Server Components are async by default. You fetch data directly with await — no wrappers needed:
// app/products/page.tsx
async function getProducts() {
const res = await fetch('https://api.example.com/products', {
cache: 'no-store', // SSR — always fresh
// next: { revalidate: 60 } // ISR — revalidate every 60s
// cache: 'force-cache' // SSG — cache indefinitely
})
if (!res.ok) throw new Error('Failed to fetch products')
return res.json()
}
export default async function ProductsPage() {
const products = await getProducts()
return (
<ul>
{products.map((product: { id: string; name: string }) => (
<li key={product.id}>{product.name}</li>
))}
</ul>
)
}Dynamic Routes and generateStaticParams
// app/blog/[slug]/page.tsx
interface Props {
params: Promise<{ slug: string }>
}
export default async function BlogPost({ params }: Props) {
const { slug } = await params
const post = await getPost(slug)
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.html }} />
</article>
)
}
// Pre-generate static pages at build time
export async function generateStaticParams() {
const posts = await getAllPosts()
return posts.map((post) => ({ slug: post.slug }))
}
// Generate per-page metadata dynamically
export async function generateMetadata({ params }: Props) {
const { slug } = await params
const post = await getPost(slug)
return {
title: post.title,
description: post.excerpt,
openGraph: { images: [post.coverImage] },
}
}Nested Layouts
Layouts persist across navigation within their segment — the React tree is not unmounted, so scroll position and state are preserved:
// app/dashboard/layout.tsx
export default function DashboardLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<div className="flex">
<aside className="sidebar">
<nav>
<a href="/dashboard">Overview</a>
<a href="/dashboard/analytics">Analytics</a>
<a href="/dashboard/settings">Settings</a>
</nav>
</aside>
<section className="content">{children}</section>
</div>
)
}Navigating from /dashboard to /dashboard/settings does not remount the sidebar — it stays mounted and only the children slot updates.
Built-in Loading and Error States
Create a loading.tsx file in any segment to show a skeleton while the page's data fetches. Next.js automatically wraps the page.tsx in a <Suspense> boundary using this component:
// app/dashboard/loading.tsx
export default function DashboardLoading() {
return (
<div className="animate-pulse space-y-4">
<div className="h-8 w-64 bg-gray-200 rounded" />
<div className="h-32 bg-gray-200 rounded" />
</div>
)
}Error boundaries must be Client Components (they use React class component error boundary behavior under the hood):
// app/dashboard/error.tsx
'use client'
export default function DashboardError({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<div className="error-container">
<h2>Something went wrong loading the dashboard.</h2>
<p>{error.message}</p>
<button onClick={reset}>Try again</button>
</div>
)
}Route Handlers (API Routes)
// app/api/users/route.ts
import { NextRequest, NextResponse } from 'next/server'
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const page = Number(searchParams.get('page')) || 1
const users = await db.users.findAll({ page, limit: 20 })
return NextResponse.json({ data: users, page })
}
export async function POST(request: NextRequest) {
const body = await request.json()
const user = await db.users.create(body)
return NextResponse.json(user, { status: 201 })
}Streaming with Suspense
Wrap slow Server Components in <Suspense> to stream them independently — the page shell renders immediately while each section loads in parallel:
// app/dashboard/page.tsx
import { Suspense } from 'react'
export default function DashboardPage() {
return (
<main>
<h1>Dashboard</h1> {/* Renders instantly */}
<Suspense fallback={<StatsSkeleton />}>
<StatsSection /> {/* Streams in when data is ready */}
</Suspense>
<Suspense fallback={<OrdersSkeleton />}>
<RecentOrders /> {/* Streams in independently */}
</Suspense>
</main>
)
}
async function StatsSection() {
const stats = await getStats() // Potentially slow DB query
return <StatsGrid stats={stats} />
}Common Mistakes
Using getServerSideProps in the app/ directory. It does not exist in App Router. Fetch directly in async Server Components instead.
Adding 'use client' to components that do not need it. Every 'use client' boundary ships its subtree as JavaScript to the browser. Keep Client Components as leaf nodes.
Reading params synchronously in Next.js 15. params is now a Promise — always await params before destructuring.
Not adding a root layout.tsx. Next.js will error without one. It must render the html and body tags.
Best Practices
- Co-locate each route's data fetching inside the page component rather than in a parent layout — it keeps data dependencies explicit.
- Use
generateStaticParamsfor dynamic routes with a finite set of values (blog slugs, product IDs) to pre-render them at build time. - Place Client Components at the leaf level of your component tree — use Server Components for everything above that does not need interactivity.
- Wrap independent slow sections in
<Suspense>to parallelize streaming and give users a faster perceived load time. - Export
metadataorgenerateMetadatafrom every page for SEO — the App Router handles title, description, and Open Graph tags natively.
Key Takeaways
- The App Router uses the
app/directory where every folder is a URL segment and special files (layout.tsx,loading.tsx,error.tsx) control the UI. - Every component in the App Router is a Server Component by default — add
'use client'only when you need hooks or browser APIs. - Data fetching uses plain
async/awaitin Server Components;getServerSidePropsandgetStaticPropsno longer exist in the App Router. - Nested
layout.tsxfiles persist across navigation within their segment, preserving state and scroll position without remounting. loading.tsxautomatically wraps the page in a Suspense boundary — no manual<Suspense>wrapper needed for the primary page content.error.tsxmust be a Client Component and receives anerrorprop and aresetfunction to retry rendering.generateStaticParamsreplacesgetStaticPaths— return an array of param objects and Next.js pre-renders each one at build time.- Wrapping independent async Server Components in
<Suspense>enables streaming, so the page shell renders immediately while each section loads in parallel.
Advertisement