Next.js 15 App Router: The Complete Guide for 2026
Master Next.js 15 App Router — from file-based routing and server components to streaming, caching, middleware, and deployment. Everything you need to build modern React apps.
Why Next.js 15?
Next.js has become the default framework for production React applications. Version 15 brings significant improvements to the App Router, making server-first rendering the standard approach.
What's changed:
- Server Components are the default (no
"use client"needed for server code) - Improved streaming and partial prerendering
- Better caching controls with
staleTimesconfiguration - Enhanced TypeScript support with typed routes
- Turbopack is now stable for development
App Router vs Pages Router
The App Router (introduced in Next.js 13) replaced the Pages Router as the recommended approach:
Pages Router (legacy): App Router (current):
pages/ app/
index.tsx page.tsx
about.tsx about/page.tsx
blog/[slug].tsx blog/[slug]/page.tsx
_app.tsx layout.tsx
_document.tsx (not needed)
api/hello.ts api/hello/route.ts
Key differences:
- Layouts are nested and preserved across navigation
- Loading and error states are built into the file system
- Server Components reduce client-side JavaScript
- Route handlers replace API routes
File-Based Routing
Every folder in the app/ directory becomes a route. Special files control behavior:
app/
layout.tsx # Root layout (wraps all pages)
page.tsx # Home page (/)
loading.tsx # Loading UI for /
error.tsx # Error boundary for /
not-found.tsx # 404 page
dashboard/
layout.tsx # Dashboard layout (nested)
page.tsx # /dashboard
settings/
page.tsx # /dashboard/settings
blog/
page.tsx # /blog (list)
[slug]/
page.tsx # /blog/my-post (dynamic)
(marketing)/ # Route group (no URL segment)
about/page.tsx # /about
pricing/page.tsx # /pricing
Dynamic Routes
// app/blog/[slug]/page.tsx
interface PageProps {
params: Promise<{ slug: string }>;
}
export default async function BlogPost({ params }: PageProps) {
const { slug } = await params;
const post = await getPost(slug);
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
);
}
// Generate static pages at build time
export async function generateStaticParams() {
const posts = await getAllPosts();
return posts.map((post) => ({ slug: post.slug }));
}Catch-All Routes
app/docs/[...slug]/page.tsx → /docs/a, /docs/a/b, /docs/a/b/c
app/docs/[[...slug]]/page.tsx → /docs, /docs/a, /docs/a/b (optional)
Server Components vs Client Components
This is the most important concept in the App Router.
Server Components (Default)
// app/users/page.tsx — This is a Server Component by default
// It runs on the server, never shipped to the browser
import { db } from "@/lib/database";
export default async function UsersPage() {
// Direct database access — no API needed
const users = await db.user.findMany();
return (
<ul>
{users.map((user) => (
<li key={user.id}>{user.name} — {user.email}</li>
))}
</ul>
);
}Server Component benefits:
- Zero JavaScript sent to client for this component
- Direct database/filesystem access
- Secrets stay on the server (API keys, tokens)
- Large dependencies don't increase bundle size
Client Components
"use client"; // This directive makes it a Client Component
import { useState } from "react";
export default function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(count + 1)}>
Clicked {count} times
</button>
);
}When to use Client Components:
useState,useEffect,useRef— any React hooks- Event handlers (
onClick,onChange) - Browser APIs (
localStorage,window) - Third-party libraries that use hooks
The Pattern: Server Parent, Client Child
// app/dashboard/page.tsx (Server Component)
import { db } from "@/lib/database";
import Chart from "@/components/Chart"; // Client Component
export default async function Dashboard() {
const data = await db.analytics.getMetrics();
return (
<div>
<h1>Dashboard</h1>
{/* Pass server data to client component as props */}
<Chart data={data} />
</div>
);
}Layouts and Templates
Root Layout (Required)
// app/layout.tsx
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<nav>...</nav>
<main>{children}</main>
<footer>...</footer>
</body>
</html>
);
}Nested Layouts
// app/dashboard/layout.tsx
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex">
<aside className="w-64">
<DashboardNav />
</aside>
<div className="flex-1">{children}</div>
</div>
);
}Layouts persist across navigation — the sidebar won't re-mount when you navigate between dashboard pages.
Templates (Re-mount on Navigation)
// app/dashboard/template.tsx
// Same API as layout, but re-mounts on every navigation
export default function DashboardTemplate({ children }: { children: React.ReactNode }) {
return <div className="animate-fadeIn">{children}</div>;
}Data Fetching
Server Components (Recommended)
// Direct async/await in Server Components
export default async function ProductsPage() {
const products = await fetch("https://api.example.com/products", {
next: { revalidate: 3600 }, // Revalidate every hour
}).then((res) => res.json());
return <ProductList products={products} />;
}Caching Strategies
// Static (cached forever until redeployed)
fetch("https://api.example.com/data", { cache: "force-cache" });
// Revalidate every 60 seconds
fetch("https://api.example.com/data", { next: { revalidate: 60 } });
// Dynamic (never cached)
fetch("https://api.example.com/data", { cache: "no-store" });Parallel Data Fetching
export default async function Dashboard() {
// Start both requests simultaneously
const [users, analytics] = await Promise.all([
getUsers(),
getAnalytics(),
]);
return (
<>
<UserList users={users} />
<AnalyticsChart data={analytics} />
</>
);
}Server Actions
Server Actions let you run server code from client components without creating API routes.
// app/actions.ts
"use server";
import { db } from "@/lib/database";
import { revalidatePath } from "next/cache";
export async function createPost(formData: FormData) {
const title = formData.get("title") as string;
const content = formData.get("content") as string;
await db.post.create({ data: { title, content } });
revalidatePath("/blog");
}// app/blog/new/page.tsx
import { createPost } from "@/app/actions";
export default function NewPost() {
return (
<form action={createPost}>
<input name="title" placeholder="Post title" required />
<textarea name="content" placeholder="Write your post..." required />
<button type="submit">Publish</button>
</form>
);
}With Client-Side Validation
"use client";
import { useActionState } from "react";
import { createPost } from "@/app/actions";
export default function NewPostForm() {
const [state, formAction, isPending] = useActionState(createPost, null);
return (
<form action={formAction}>
<input name="title" required />
<button disabled={isPending}>
{isPending ? "Publishing..." : "Publish"}
</button>
{state?.error && <p className="text-red-500">{state.error}</p>}
</form>
);
}Loading and Error Handling
Loading States
// app/dashboard/loading.tsx
export default function Loading() {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full" />
</div>
);
}This automatically wraps the page in a <Suspense> boundary.
Error Boundaries
"use client"; // Error components must be Client Components
export default function Error({
error,
reset,
}: {
error: Error;
reset: () => void;
}) {
return (
<div className="text-center py-16">
<h2>Something went wrong</h2>
<p>{error.message}</p>
<button onClick={reset}>Try again</button>
</div>
);
}Not Found
// app/blog/[slug]/page.tsx
import { notFound } from "next/navigation";
export default async function BlogPost({ params }: PageProps) {
const { slug } = await params;
const post = await getPost(slug);
if (!post) notFound(); // Renders not-found.tsx
return <article>{post.title}</article>;
}Middleware
// src/middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function middleware(request: NextRequest) {
// Redirect /old-page to /new-page
if (request.nextUrl.pathname === "/old-page") {
return NextResponse.redirect(new URL("/new-page", request.url));
}
// Add security headers
const response = NextResponse.next();
response.headers.set("X-Frame-Options", "DENY");
response.headers.set("X-Content-Type-Options", "nosniff");
return response;
}
export const config = {
matcher: ["/((?!api|_next/static|_next/image|favicon.ico).*)"],
};Metadata and SEO
// app/blog/[slug]/page.tsx
import type { Metadata } from "next";
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { slug } = await params;
const post = await getPost(slug);
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
images: [post.coverImage],
},
twitter: {
card: "summary_large_image",
},
};
}Route Handlers (API Routes)
// app/api/posts/route.ts
import { NextRequest, NextResponse } from "next/server";
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const page = parseInt(searchParams.get("page") || "1");
const posts = await db.post.findMany({
skip: (page - 1) * 10,
take: 10,
});
return NextResponse.json(posts);
}
export async function POST(request: NextRequest) {
const body = await request.json();
const post = await db.post.create({ data: body });
return NextResponse.json(post, { status: 201 });
}Deployment
Vercel (Recommended)
# Install Vercel CLI
npm i -g vercel
# Deploy
vercelSelf-Hosted (Node.js)
# Build
npm run build
# Start production server
npm start
# Runs on port 3000 by defaultDocker
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]Add to next.config.ts:
const nextConfig = {
output: "standalone",
};Quick Reference
| Feature | File | Purpose |
|---------|------|---------|
| Page | page.tsx | UI for a route |
| Layout | layout.tsx | Shared UI (persists) |
| Template | template.tsx | Shared UI (re-mounts) |
| Loading | loading.tsx | Loading state |
| Error | error.tsx | Error boundary |
| Not Found | not-found.tsx | 404 page |
| Route Handler | route.ts | API endpoint |
| Middleware | middleware.ts | Request interception |
The mental model: Server Components for data and layout, Client Components for interactivity, Server Actions for mutations. Start on the server, add client behavior only where needed.