The Next.js App Router is the modern routing and rendering foundation of Next.js, introduced in version 13 and built on React Server Components. Routes are defined by folders inside the `app/` directory, where special files like `page.tsx`, `layout.tsx`, and `loading.tsx` compose the UI for each URL segment. Components are server-first by default: they run only on the server, fetch data close to where they render, and ship zero JavaScript to the browser, while the `"use client"` directive opts individual components into hydration for interactivity. Compared to the Pages Router, this replaces the single `getServerSideProps` funnel with per-component data fetching, granular caching, and streaming - the server can send static parts of a page immediately and stream slower sections as they resolve. This post is a code-first walkthrough of every major App Router concept: routing, layouts, Server vs Client Components, caching, streaming, Server Actions, route handlers, ISR, and migration.
How Does File-System Routing Work in app/?
Routing in the App Router is defined by folders inside `app/`. Each folder is a URL segment, and a `page.tsx` file makes that segment publicly routable. Special files such as `layout.tsx`, `loading.tsx`, `error.tsx`, and `route.ts` add behavior to a segment without themselves being routes.
The core special files the router recognizes are:
- page.tsx — the unique UI of a route, making the segment navigable.
- layout.tsx — shared UI that wraps a segment and all of its children; preserved across navigation.
- template.tsx — like a layout but re-mounted on every navigation (fresh state each time).
- loading.tsx — an instant Suspense fallback shown while the segment streams.
- error.tsx — a Client Component error boundary for the segment.
- not-found.tsx — UI rendered when notFound() is called or a route is unmatched.
- route.ts — a Route Handler that exposes HTTP methods (GET, POST, ...) instead of UI.
Dynamic segments use square brackets. A folder named `[slug]` matches a single segment, `[...slug]` is a catch-all, and `[[...slug]]` is an optional catch-all. Parentheses create route groups like `(marketing)` that organize files without affecting the URL, while an underscore prefix like `_lib` marks a private folder that the router ignores.
app/
├── layout.tsx # Root layout (required, wraps everything)
├── page.tsx # Route: /
├── loading.tsx # Suspense fallback for /
├── blog/
│ ├── page.tsx # Route: /blog
│ └── [slug]/
│ ├── page.tsx # Route: /blog/:slug
│ └── error.tsx # Error boundary for a single post
├── (marketing)/ # Route group — not part of the URL
│ └── about/page.tsx # Route: /about
└── api/
└── posts/route.ts # Route Handler: /api/postsHow Do Layouts and Templates Differ?
A layout wraps its segment and receives a `children` prop. The root layout is mandatory and must render the `<html>` and `<body>` tags, since it replaces the `_app` and `_document` files from the Pages Router. Layouts do not re-render on navigation between sibling routes, which is what makes shared shells like sidebars keep their scroll position and state.
// app/layout.tsx
import type { ReactNode } from 'react';
import './globals.css';
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang='en'>
<body>
<header>My Site</header>
<main>{children}</main>
</body>
</html>
);
}A template is nearly identical but creates a fresh instance for each child on navigation, so state is reset and effects re-run. Use a layout by default; reach for a template only when you need per-navigation behavior such as enter animations or resetting a form when the user moves to a sibling page.
// app/dashboard/template.tsx
'use client';
import { useEffect } from 'react';
export default function Template({ children }: { children: React.ReactNode }) {
useEffect(() => {
// Runs on every navigation into a child route
console.log('page view');
}, []);
return <div className='fade-in'>{children}</div>;
}When Should You Use Server vs Client Components?
Every component in the App Router is a Server Component by default. Server Components run only on the server: they can read the filesystem, query a database directly, and use secrets, and their code is never shipped to the browser. This is the single biggest reason App Router apps ship less JavaScript. When you need interactivity — state, effects, event handlers, or browser-only APIs — you opt into a Client Component with the `'use client'` directive at the top of the file.
// app/products/page.tsx — Server Component (no directive)
import { db } from '@/lib/db';
import AddToCart from './add-to-cart';
export default async function ProductsPage() {
// Direct data access on the server — never sent to the client
const products = await db.product.findMany();
return (
<ul>
{products.map((p) => (
<li key={p.id}>
{p.name}
{/* Interactive island rendered as a Client Component */}
<AddToCart id={p.id} />
</li>
))}
</ul>
);
}// app/products/add-to-cart.tsx — Client Component
'use client';
import { useState } from 'react';
export default function AddToCart({ id }: { id: string }) {
const [added, setAdded] = useState(false);
return (
<button onClick={() => setAdded(true)}>
{added ? 'Added' : 'Add to cart'}
</button>
);
}The mental model that scales: keep components on the server as long as possible, and push the 'use client' boundary as far down the tree as you can. A Server Component can render Client Components, but a Client Component can only import other Client Components.
You can still pass Server Components into Client Components through the `children` prop. This composition pattern lets an interactive wrapper (say a collapsible panel) wrap server-rendered content without turning that content into client JavaScript.
How Does fetch Caching Work on the Server?
Because Server Components are async functions, data fetching is just `await`. There is no `getServerSideProps` or `getStaticProps`; you fetch data directly in the component that needs it. Next.js extends the native `fetch` API with caching and revalidation options so a single call can be static, dynamic, or time-based.
// Cached indefinitely and reused across requests (build-time / static)
const staticData = await fetch('https://api.example.com/config', {
cache: 'force-cache',
});
// Never cached — fetched fresh on every request (dynamic)
const dynamicData = await fetch('https://api.example.com/live', {
cache: 'no-store',
});
// Time-based revalidation: cached, refreshed at most every 60 seconds (ISR)
const revalidated = await fetch('https://api.example.com/posts', {
next: { revalidate: 60 },
});
// Tag a response so it can be invalidated on demand
const tagged = await fetch('https://api.example.com/posts', {
next: { tags: ['posts'] },
});When several components need the same data, call `fetch` in each of them with the same URL and options. React automatically deduplicates identical requests within a single render pass, so you do not need to lift the fetch up or thread props through the tree. For non-fetch data sources such as an ORM query, wrap the function in React `cache()` to get the same request-scoped memoization.
// lib/posts.ts
import { cache } from 'react';
import { db } from '@/lib/db';
// Deduplicated per request — call it from as many components as you like
export const getPost = cache(async (slug: string) => {
return db.post.findUnique({ where: { slug } });
});How Does Streaming with Suspense and loading.tsx Work?
Streaming lets the server send HTML in chunks as it becomes ready instead of blocking the entire page on the slowest data request. The App Router wires this up automatically: adding a `loading.tsx` file wraps the segment in a Suspense boundary and shows the fallback instantly while the page streams in behind it.
// app/dashboard/loading.tsx
export default function Loading() {
return <p>Loading dashboard…</p>;
}For finer control, wrap individual slow components in your own Suspense boundaries. The fast parts of the page render immediately, and each slow section pops in when its data resolves — a far better experience than a single spinner blocking everything.
// app/dashboard/page.tsx
import { Suspense } from 'react';
async function Revenue() {
const data = await fetch('https://api.example.com/revenue', {
next: { revalidate: 30 },
}).then((r) => r.json());
return <div>Revenue: {data.total}</div>;
}
async function LatestOrders() {
const orders = await fetch('https://api.example.com/orders', {
cache: 'no-store',
}).then((r) => r.json());
return <ul>{orders.map((o: { id: string }) => <li key={o.id}>{o.id}</li>)}</ul>;
}
export default function DashboardPage() {
return (
<section>
<h1>Dashboard</h1>
<Suspense fallback={<p>Loading revenue…</p>}>
<Revenue />
</Suspense>
<Suspense fallback={<p>Loading orders…</p>}>
<LatestOrders />
</Suspense>
</section>
);
}What Are Server Actions?
Server Actions are async functions that run on the server and can be invoked directly from your components — including from a form `action` — without you writing an API endpoint. Mark a function with the `'use server'` directive, and Next.js handles serializing the call, running it on the server, and returning the result. This is the App Router's answer to mutations.
// app/posts/actions.ts
'use server';
import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';
import { db } from '@/lib/db';
export async function createPost(formData: FormData) {
const title = String(formData.get('title'));
const body = String(formData.get('body'));
await db.post.create({ data: { title, body } });
// Purge the cached listing so the new post shows up
revalidatePath('/posts');
redirect('/posts');
}// app/posts/new/page.tsx — a Server Component using the action directly
import { createPost } from '../actions';
export default function NewPostPage() {
return (
<form action={createPost}>
<input name='title' placeholder='Title' />
<textarea name='body' placeholder='Body' />
<button type='submit'>Publish</button>
</form>
);
}From a Client Component you can enhance the experience with `useActionState` for validation results and `useFormStatus` for pending UI, giving you progressive enhancement: the form works without JavaScript and gets richer once hydrated.
// app/posts/new/submit-button.tsx
'use client';
import { useFormStatus } from 'react-dom';
export function SubmitButton() {
const { pending } = useFormStatus();
return (
<button type='submit' disabled={pending}>
{pending ? 'Publishing…' : 'Publish'}
</button>
);
}Route Handlers
When you need a traditional HTTP endpoint — a webhook receiver, a public JSON API, an OAuth callback — define a `route.ts` file exporting functions named after HTTP methods. Route Handlers use the Web `Request` and `Response` APIs and support the same caching and revalidation controls as page fetches.
// app/api/posts/route.ts
import { NextResponse } from 'next/server';
import { db } from '@/lib/db';
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const limit = Number(searchParams.get('limit') ?? '10');
const posts = await db.post.findMany({ take: limit });
return NextResponse.json(posts);
}
export async function POST(request: Request) {
const body = await request.json();
const post = await db.post.create({ data: body });
return NextResponse.json(post, { status: 201 });
}When Is a Route Static vs Dynamic?
Next.js decides between static and dynamic rendering per route based on what your code uses. A route stays static (rendered once, cached, and reused) unless it opts into dynamic behavior — for example by calling `cookies()`, `headers()`, reading `searchParams`, or fetching with `cache: 'no-store'`. Incremental Static Regeneration (ISR) sits in between: pages are served statically but rebuilt in the background on a schedule via `revalidate`.
// Route Segment Config — declare rendering behavior explicitly
export const dynamic = 'force-static'; // or 'force-dynamic', 'auto'
export const revalidate = 3600; // ISR: rebuild at most once per hour
// Pre-render dynamic params at build time
export async function generateStaticParams() {
const posts = await fetch('https://api.example.com/posts').then((r) => r.json());
return posts.map((post: { slug: string }) => ({ slug: post.slug }));
}On-demand revalidation is the most powerful option: instead of waiting for a timer, you invalidate cached data the instant it changes — typically from a Server Action or a webhook. Use `revalidatePath` to purge a specific route and `revalidateTag` to purge every fetch tagged with a label, no matter which route it lives on.
// app/api/revalidate/route.ts
import { revalidateTag, revalidatePath } from 'next/cache';
import { NextResponse } from 'next/server';
export async function POST(request: Request) {
const { tag, path } = await request.json();
if (tag) revalidateTag(tag); // invalidate fetches tagged with 'posts'
if (path) revalidatePath(path); // invalidate a specific route
return NextResponse.json({ revalidated: true, now: Date.now() });
}The Metadata API
SEO is handled by exporting metadata from a `layout.tsx` or `page.tsx`, replacing manual `<Head>` tags. Export a static `metadata` object for constant values, or an async `generateMetadata` function when the tags depend on route data such as a blog post title. Metadata merges down the tree, so a root layout can set defaults that individual pages override.
// app/blog/[slug]/page.tsx
import type { Metadata } from 'next';
import { getPost } from '@/lib/posts';
export async function generateMetadata(
{ params }: { params: Promise<{ slug: string }> }
): Promise<Metadata> {
const { slug } = await params;
const post = await getPost(slug); // deduplicated with the page's own fetch
return {
title: post?.title ?? 'Post',
description: post?.excerpt,
openGraph: {
title: post?.title,
images: [{ url: post?.coverImage ?? '/og-default.png' }],
},
};
}Error and Not-Found Boundaries
An `error.tsx` file defines a React error boundary for its segment. It must be a Client Component and receives the caught `error` plus a `reset` function to retry rendering. This isolates failures: an error in one section falls back to its boundary while the rest of the layout keeps working.
// app/blog/[slug]/error.tsx
'use client';
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div role='alert'>
<h2>Something went wrong</h2>
<p>{error.message}</p>
<button onClick={reset}>Try again</button>
</div>
);
}For expected missing data, call `notFound()` from a Server Component to render the nearest `not-found.tsx` and return a 404 status. This keeps your data-fetching code declarative — you check for the record, and if it is absent you throw the not-found signal.
// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation';
import { getPost } from '@/lib/posts';
export default async function PostPage(
{ params }: { params: Promise<{ slug: string }> }
) {
const { slug } = await params;
const post = await getPost(slug);
if (!post) notFound(); // renders app/blog/[slug]/not-found.tsx with a 404
return <article><h1>{post.title}</h1></article>;
}How Do You Migrate from the Pages Router?
The App Router and Pages Router can coexist in the same project, so you can migrate incrementally — move one route at a time from `pages/` to `app/`. Keep these mappings in mind as you port code.
Common Pages Router concepts and their App Router equivalents:
- getServerSideProps → an async Server Component with fetch using cache: 'no-store'.
- getStaticProps → an async Server Component with cached fetch or export const revalidate.
- getStaticPaths → generateStaticParams inside the dynamic segment.
- _app.tsx and _document.tsx → the root app/layout.tsx that renders <html> and <body>.
- next/head or Head → the metadata export or generateMetadata function.
- pages/api routes → route.ts Route Handlers using Web Request/Response.
- useRouter from next/router → hooks from next/navigation (useRouter, usePathname, useSearchParams).
- API-based mutations → Server Actions with 'use server'.
The biggest mental shift is that components are server-first. Code that reached for `useEffect` to fetch data on mount usually becomes a plain `await` in a Server Component, and only genuinely interactive leaves need `'use client'`. Resist marking whole pages as client components out of habit — that throws away the bundle-size and data-locality wins the App Router is built to give you.
Key Takeaways
The essentials to carry into your next App Router project:
- Folders define routes; special files (page, layout, loading, error, route) add behavior.
- Components are Server Components by default — opt into interactivity with 'use client' at the lowest level possible.
- Fetch data directly in async Server Components; control caching with cache and next.revalidate, and rely on request deduplication.
- Stream slow UI with Suspense and loading.tsx so users see content as it becomes ready.
- Use Server Actions for mutations and Route Handlers for HTTP endpoints — often you need no separate API layer.
- Routes are static by default; choose ISR with revalidate or dynamic rendering deliberately, and invalidate on demand with revalidatePath and revalidateTag.
- Handle SEO with the Metadata API and failures with error.tsx and notFound(); migrate from the Pages Router route by route.
