Next.js App Router: The Complete Guide
Master Next.js 15 App Router — Server Components, Client Components, data fetching, caching, routing, layouts, loading states, API routes, authentication, and deployment. With real production patterns.
App Router vs Pages Router
Next.js has two routing systems. The App Router (introduced in Next.js 13, stable in 14) is the future:
| | App Router (app/) | Pages Router (pages/) |
|--|---------------------|------------------------|
| Default component type | Server Component | Client Component |
| Data fetching | async/await in components | getServerSideProps / getStaticProps |
| Layouts | Nested layout files | _app.tsx + manual nesting |
| Loading states | loading.tsx file | Manual skeleton components |
| Error handling | error.tsx file | Error boundaries |
| Caching | Granular, per-fetch | Page-level |
Use the App Router for all new projects.
Project Structure
app/
├── layout.tsx ← root layout (wraps everything)
├── page.tsx ← homepage (/)
├── globals.css
├── dashboard/
│ ├── layout.tsx ← dashboard layout (wraps /dashboard/*)
│ ├── page.tsx ← /dashboard
│ └── settings/
│ └── page.tsx ← /dashboard/settings
├── blog/
│ ├── page.tsx ← /blog (list)
│ └── [slug]/
│ └── page.tsx ← /blog/my-post (dynamic)
└── api/
└── orders/
└── route.ts ← /api/orders (API endpoint)File conventions:
page.tsx— the UI for a route (makes it accessible)layout.tsx— wraps children, persists across navigationsloading.tsx— shown while the page is loading (Suspense boundary)error.tsx— shown when a component throwsnot-found.tsx— shown whennotFound()is calledroute.ts— API endpoint (no UI)
Server Components
By default, every component in the app/ directory is a Server Component — it runs only on the server, never in the browser.
// app/dashboard/page.tsx — Server Component (default)
// This runs on the server — no "use client", no useEffect needed
export default async function DashboardPage() {
// Direct database access — no API call needed
const stats = await db.query(`
SELECT COUNT(*) as orders, SUM(total) as revenue
FROM Orders WHERE created_at > NOW() - INTERVAL '30 days'
`);
const recentOrders = await db.orders.findMany({
orderBy: { createdAt: "desc" },
take: 10,
});
return (
<main>
<h1>Dashboard</h1>
<StatsCards stats={stats} />
<OrderTable orders={recentOrders} />
</main>
);
}Server Components:
- Can access databases, file system, env variables directly
- Never expose secrets to the browser
- Reduce JavaScript bundle size (component code stays on server)
- Cannot use
useState,useEffect, event handlers, or browser APIs
Client Components
Add "use client" at the top for components that need interactivity:
// components/counter.tsx
"use client";
import { useState } from "react";
export function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(c => c + 1)}>Increment</button>
</div>
);
}// app/page.tsx — Server Component
import { Counter } from "@/components/counter";
export default function HomePage() {
return (
<div>
<h1>Welcome</h1>
<Counter /> {/* Client Component inside Server Component ✅ */}
</div>
);
}Key rule: push "use client" as far down the component tree as possible. Keep the data-fetching at the top as Server Components; only the interactive leaf nodes need to be Client Components.
Data Fetching
Server Component data fetching
// Async server component — fetch data at render time
export default async function ProductsPage() {
// fetch is automatically deduplicated and cached
const products = await fetch("https://api.example.com/products", {
next: { revalidate: 3600 } // cache for 1 hour, then revalidate
}).then(r => r.json());
return <ProductList products={products} />;
}Parallel data fetching
export default async function OrderDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
// Fetch in parallel — don't await sequentially
const [order, customer, inventory] = await Promise.all([
getOrder(id),
getCustomer(id),
getInventory(id),
]);
return <OrderDetail order={order} customer={customer} inventory={inventory} />;
}Cache strategies
// Static — cached indefinitely, rebuild to update
fetch(url, { cache: "force-cache" });
// Revalidate — cached, refreshed in background after N seconds
fetch(url, { next: { revalidate: 60 } });
// Dynamic — never cached, always fresh
fetch(url, { cache: "no-store" });
// Tag-based — invalidate specific caches on demand
fetch(url, { next: { tags: ["orders"] } });
// Revalidate by tag from a Server Action or API route
import { revalidateTag } from "next/cache";
revalidateTag("orders");Layouts
Layouts persist across navigations in their segment — ideal for navigation, sidebars, auth wrappers:
// app/layout.tsx — root layout
import type { Metadata } from "next";
export const metadata: Metadata = {
title: { default: "Learnixo", template: "%s | Learnixo" },
description: "Free developer learning platform",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<Header />
<main>{children}</main>
<Footer />
</body>
</html>
);
}// app/dashboard/layout.tsx — nested layout
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex">
<Sidebar /> {/* Persists when navigating between dashboard pages */}
<div className="flex-1">{children}</div>
</div>
);
}Dynamic Routes
// app/blog/[slug]/page.tsx
interface Props {
params: Promise<{ slug: string }>;
}
export default async function BlogPostPage({ params }: Props) {
const { slug } = await params;
const post = await getPostBySlug(slug);
if (!post) notFound();
return <article><h1>{post.title}</h1><MDXContent source={post.content} /></article>;
}
// Generate static pages at build time (optional)
export async function generateStaticParams() {
const posts = await getAllPosts();
return posts.map(post => ({ slug: post.slug }));
}
// SEO metadata per page
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const post = await getPostBySlug(slug);
return {
title: post?.title ?? "Not Found",
description: post?.excerpt,
openGraph: { images: [post?.coverImage ?? ""] },
};
}Loading and Error States
// app/dashboard/loading.tsx — shown instantly while page loads
export default function DashboardLoading() {
return (
<div className="space-y-4 p-8">
<div className="h-8 w-48 bg-muted animate-pulse rounded" />
<div className="grid grid-cols-3 gap-4">
{[1, 2, 3].map(i => (
<div key={i} className="h-32 bg-muted animate-pulse rounded-xl" />
))}
</div>
</div>
);
}// app/dashboard/error.tsx — shown when the page throws
"use client"; // error components must be Client Components
export default function DashboardError({
error,
reset,
}: {
error: Error;
reset: () => void;
}) {
return (
<div className="text-center py-20">
<h2 className="text-xl font-bold mb-4">Something went wrong</h2>
<p className="text-muted-foreground mb-6">{error.message}</p>
<button onClick={reset} className="btn btn-primary">Try again</button>
</div>
);
}API Routes
// app/api/orders/route.ts
import { NextRequest, NextResponse } from "next/server";
export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl;
const status = searchParams.get("status") ?? "all";
const orders = await db.orders.findMany({
where: status !== "all" ? { status } : undefined,
orderBy: { createdAt: "desc" },
take: 20,
});
return NextResponse.json({ orders });
}
export async function POST(request: NextRequest) {
const body = await request.json();
// Validate
const parsed = createOrderSchema.safeParse(body);
if (!parsed.success)
return NextResponse.json({ errors: parsed.error.flatten() }, { status: 400 });
const order = await db.orders.create({ data: parsed.data });
return NextResponse.json({ order }, { status: 201 });
}// app/api/orders/[id]/route.ts — dynamic API route
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const order = await db.orders.findUnique({ where: { id } });
if (!order) return NextResponse.json({ error: "Not found" }, { status: 404 });
return NextResponse.json({ order });
}Server Actions
Server Actions let you run server code directly from a form or button click — no API route needed:
// app/orders/actions.ts
"use server";
import { revalidatePath } from "next/cache";
export async function createOrderAction(formData: FormData) {
const customerId = formData.get("customerId") as string;
const items = JSON.parse(formData.get("items") as string);
// Validate
if (!customerId) throw new Error("Customer ID required");
// Run on server — direct DB access
const order = await db.orders.create({
data: { customerId, items, status: "pending" }
});
revalidatePath("/orders"); // revalidate the orders list cache
return { orderId: order.id };
}// app/orders/new/page.tsx — use the server action in a form
import { createOrderAction } from "../actions";
export default function NewOrderPage() {
return (
<form action={createOrderAction}>
<input name="customerId" placeholder="Customer ID" />
<input name="items" type="hidden" value="[]" />
<button type="submit">Create Order</button>
</form>
);
}Middleware — Auth Guard
// middleware.ts (root of project)
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function middleware(request: NextRequest) {
const token = request.cookies.get("auth-token")?.value;
const isProtected = request.nextUrl.pathname.startsWith("/dashboard");
if (isProtected && !token) {
const loginUrl = new URL("/login", request.url);
loginUrl.searchParams.set("callbackUrl", request.nextUrl.pathname);
return NextResponse.redirect(loginUrl);
}
return NextResponse.next();
}
export const config = {
matcher: ["/dashboard/:path*", "/api/:path*"],
};Key Takeaways
- Server Components by default — only add
"use client"when you need interactivity - Push
"use client"down — keep data fetching in Server Components, interactivity in leaf nodes - Parallel data fetching with
Promise.all— never await sequentially if not dependent - Cache strategies per fetch —
force-cache,revalidate: N,no-store, tag-based - Layouts persist across navigations — use them for nav, sidebars, auth wrappers
loading.tsxanderror.tsxhandle loading and error states automatically- Server Actions replace simple API routes — run server code from forms, no round-trip needed
- Middleware for auth guards, redirects, A/B testing — runs before any route handler
Enjoyed this article?
Explore the Frontend Engineering learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.