What you'll learn
Quick Answer
The App Router is Next.js's routing system based on theapp/directory. Files with reserved names -page,layout,loading,error,route- define the UI and API for each URL segment. Components are React Server Components by default and only run in the browser when you add the"use client"directive. Data caching changed from automatic to opt-in.
The app directory and its file names
In the App Router, folders are URL segments and specific filenames carry meaning. Inside app/:
page.tsx- the UI for that route.app/about/page.tsxserves/about.layout.tsx- a shared shell that wraps child pages and persists across navigation within its segment. The root layout is required and must render<html>and<body>.loading.tsx- shown automatically while the segment's async work is pending, via React Suspense.error.tsx- a Client Component that catches errors thrown while rendering that segment.route.ts- an API endpoint instead of a page. One folder cannot have bothpageandroute.
Dynamic segments use square brackets: app/blog/[slug]/page.tsx matches /blog/anything. Folders in parentheses like app/(marketing)/ group routes without adding a URL segment. Because the file tree is the routing table, there is no separate route config to keep in sync.
Server Components are the default
Every component in app/ is a React Server Component unless you say otherwise. It runs on the server, its code is never sent to the browser, and it can be async:
// app/page.tsx - a Server Component
export default async function HomePage() {
const res = await fetch("https://api.example.com/stats", { cache: "force-cache" });
const stats = await res.json();
return <main><h1>{stats.total} learners</h1></main>;
}
You can await directly in the component body - no useEffect, no loading state, no client-side data fetching for the initial render. The database call or API request happens on the server and only HTML comes down.
What Server Components cannot do: use state or lifecycle hooks (useState, useEffect, useReducer), attach event handlers such as onClick, or use browser-only APIs. Those need a Client Component. In practice you keep most of the tree as Server Components and push interactivity into small Client Component leaves.
The use client boundary
Add "use client" at the top of a file to make it and everything it imports part of the browser bundle:
"use client";
import { useState } from "react";
export default function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>Clicked {count}</button>;
}
Two build errors show you the boundary is in the wrong place. Use a hook in a Server Component and the build fails with:
Error: You're importing a module that depends on `useState` into
a React Server Component module. This API is only available in
Client Components.
Attach an onClick in a Server Component and you get Event handlers cannot be passed to Client Component props.
The real gotcha is overcorrecting. Putting "use client" at the top of a large shared component drags everything it imports into the client bundle and undoes the benefit. Put the directive on the smallest component that actually needs interactivity, and keep its parents on the server.
Route handlers replace API routes
API endpoints live in route.ts files and export functions named after HTTP methods:
// app/api/health/route.ts
import { NextResponse } from "next/server";
export async function GET() {
return NextResponse.json({ status: "ok" });
}
export async function POST(request: Request) {
const body = await request.json();
return NextResponse.json({ received: body }, { status: 201 });
}
GET /api/health returns {"status":"ok"}; a POST with a JSON body echoes it back with status 201. The handler receives a standard Web Request and returns a Response - the same primitives the platform uses, not a Next-specific request/response pair.
A behavior change worth knowing: a plain GET route handler is not cached by default. Earlier Next.js versions prerendered them at build time; now they run on each request unless you opt in with export const dynamic = "force-static". The build output labels every route - a hollow circle means static, and the letter f means it renders on demand.
Caching flipped to opt-in
This is the change that most surprises people coming back to Next.js. Early App Router releases cached fetch() responses and GET route handlers aggressively by default, and you opted out. That produced stale-data bugs often enough that the defaults were walked back.
Now you opt in. The fetch options that matter:
fetch(url, { cache: "no-store" }) // never cached, forces dynamic rendering
fetch(url, { cache: "force-cache" }) // cached until you revalidate it
fetch(url, { next: { revalidate: 60 } }) // cached, refreshed at most once per 60s
You can see the effect at build time. A route whose data call passes { cache: "no-store" } is marked dynamic and runs on every request; the same route with { cache: "force-cache" } is prerendered to static HTML. GET route handlers and client-side router caching moved the same direction - less caching by default, explicit opt-in when you want it.
The practical rule: choose caching deliberately per data source, because the framework no longer guesses for you.
Mutations with Server Actions
Server Actions let a form call server code directly, with no route handler and no client-side fetch. Mark a function "use server" and pass it to a form's action:
// app/todos/actions.ts
"use server";
import { revalidatePath } from "next/cache";
import { db } from "@/lib/db";
export async function addTodo(formData: FormData) {
const text = String(formData.get("text") ?? "").trim();
if (text) await db.todo.create({ data: { text } });
revalidatePath("/todos");
}
// app/todos/page.tsx (a Server Component)
import { addTodo } from "./actions";
export default function TodosPage() {
return <form action={addTodo}><input name="text" /><button>Add</button></form>;
}
The form works before any JavaScript loads, and revalidatePath refreshes the cached page so the new row appears. One gotcha: in a file that starts with "use server", every export must be an async function. Add a plain synchronous helper and the build fails with Server Actions must be async functions - put shared non-action helpers in a separate module.
