Next.js App Router Cheat Sheet
Quick reference for the Next.js App Router — file conventions, routing, Server Components, data fetching, caching, and CLI commands.
| File | Purpose |
page.tsx | Defines a route's UI. Required to make a route publicly accessible. |
layout.tsx | Shared UI that wraps child routes. Preserves state across navigations. |
loading.tsx | Instant loading UI shown while the route segment loads (uses React Suspense). |
error.tsx | Error boundary for a route segment. Must be a Client Component. |
not-found.tsx | UI shown when notFound() is called or no matching route exists. |
route.ts | API Route Handler. Supports GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS. |
template.tsx | Like layout but creates a new instance on each navigation (state resets). |
default.tsx | Fallback UI for parallel routes when the slot has no matching content. |
global-error.tsx | Root-level error boundary. Wraps the entire <html> tag. |
middleware.ts | Runs before every request. Must be in the project root (not inside app/). |
# Typical folder structure
app/
layout.tsx # Root layout (required)
page.tsx # Home page (/)
loading.tsx # Loading state for /
error.tsx # Error boundary for /
not-found.tsx # 404 page
dashboard/
layout.tsx # Dashboard layout
page.tsx # /dashboard
settings/
page.tsx # /dashboard/settings
api/
users/
route.ts # API endpoint: /api/users
| Pattern | Folder | Matches |
| Static | app/about/page.tsx | /about |
| Dynamic | app/blog/[slug]/page.tsx | /blog/hello-world |
| Catch-all | app/docs/[...slug]/page.tsx | /docs/a, /docs/a/b/c |
| Optional catch-all | app/docs/[[...slug]]/page.tsx | /docs, /docs/a/b |
| Route group | app/(marketing)/about/page.tsx | /about (parentheses ignored in URL) |
| Parallel route | app/@modal/login/page.tsx | Named slot rendered alongside siblings |
| Intercepting route | app/@modal/(.)photo/[id]/page.tsx | Intercepts /photo/[id] in current layout |
Dynamic route params
// app/blog/[slug]/page.tsx
export default async function BlogPost({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params;
return <h1>{slug}</h1>;
}
// Catch-all: params.slug is string[]
// app/docs/[...slug]/page.tsx
// /docs/a/b/c → params.slug = ['a', 'b', 'c']
generateStaticParams (SSG for dynamic routes)
// Build static pages at build time
export async function generateStaticParams() {
const posts = await getPosts();
return posts.map((post) => ({ slug: post.slug }));
}
| Feature | Server Component (default) | Client Component |
| Directive | None needed | 'use client' at top of file |
| Data fetching | async/await directly in component | useEffect, SWR, React Query |
| State / Effects | Not available | useState, useEffect, etc. |
| Event handlers | Not available | onClick, onChange, etc. |
| Browser APIs | Not available | window, document, localStorage |
| Secrets / env vars | Safe to use (server only) | Only NEXT_PUBLIC_ vars |
| Bundle size | Zero JS sent to client | Included in client bundle |
// Server Component (default) — no directive needed
export default async function UserList() {
const users = await db.user.findMany(); // Direct DB access
return <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>;
}
// Client Component — add 'use client' directive
'use client';
import { useState } from 'react';
export default function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
}
// Fetch in a Server Component — cached by default
async function getPost(id: string) {
const res = await fetch(`https://api.example.com/posts/${id}`);
if (!res.ok) throw new Error('Failed to fetch');
return res.json();
}
// No caching (dynamic data)
await fetch(url, { cache: 'no-store' });
// Time-based revalidation (seconds)
await fetch(url, { next: { revalidate: 3600 } });
// Tag-based revalidation
await fetch(url, { next: { tags: ['posts'] } });
Revalidation
import { revalidatePath, revalidateTag } from 'next/cache';
// Revalidate a specific path
revalidatePath('/blog');
// Revalidate all fetches tagged with 'posts'
revalidateTag('posts');
// Route segment config (entire page)
export const revalidate = 60; // seconds
export const dynamic = 'force-dynamic'; // always dynamic
export const dynamic = 'force-static'; // always static
// Inline Server Action in a Server Component
export default function Page() {
async function createPost(formData: FormData) {
'use server';
const title = formData.get('title') as string;
await db.post.create({ data: { title } });
revalidatePath('/posts');
}
return (
<form action={createPost}>
<input name="title" />
<button type="submit">Create</button>
</form>
);
}
// Separate actions file
// app/actions.ts
'use server';
export async function deletePost(id: string) {
await db.post.delete({ where: { id } });
revalidatePath('/posts');
}
useActionState and useFormStatus
// Client Component using useActionState (React 19+)
'use client';
import { useActionState } from 'react';
import { submitForm } from './actions';
export function Form() {
const [state, formAction, pending] = useActionState(submitForm, null);
return (
<form action={formAction}>
<input name="email" />
<button disabled={pending}>
{pending ? 'Submitting...' : 'Submit'}
</button>
{state?.error && <p>{state.error}</p>}
</form>
);
}
// useFormStatus — reads parent form status
'use client';
import { useFormStatus } from 'react-dom';
function SubmitButton() {
const { pending } = useFormStatus();
return <button disabled={pending}>{pending ? 'Saving...' : 'Save'}</button>;
}
// Static metadata (in layout.tsx or page.tsx)
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'My App',
description: 'Built with Next.js',
openGraph: { title: 'My App', description: '...' },
};
// Dynamic metadata
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>
}): Promise<Metadata> {
const { slug } = await params;
const post = await getPost(slug);
return {
title: post.title,
description: post.excerpt,
openGraph: { images: [post.image] },
};
}
// Title template (in root layout)
export const metadata: Metadata = {
title: { template: '%s | My App', default: 'My App' },
};
// middleware.ts (project root, NOT inside app/)
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
// Redirect example
if (request.nextUrl.pathname === '/old-page') {
return NextResponse.redirect(new URL('/new-page', request.url));
}
// Rewrite example
if (request.nextUrl.pathname.startsWith('/api/v1')) {
return NextResponse.rewrite(new URL('/api/v2' +
request.nextUrl.pathname.slice(7), request.url));
}
// Add headers
const response = NextResponse.next();
response.headers.set('x-custom-header', 'hello');
return response;
}
// Matcher config — runs only on matching paths
export const config = {
matcher: ['/dashboard/:path*', '/api/:path*'],
// Exclude static files:
// matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};
// next/image — optimized images
import Image from 'next/image';
<Image
src="/hero.jpg" // or external URL
alt="Hero image"
width={800}
height={400}
priority // preload above-the-fold images
placeholder="blur" // blur-up while loading
blurDataURL="..." // base64 placeholder
/>
// Fill mode (parent must have position: relative)
<div style={{ position: 'relative', width: '100%', height: 400 }}>
<Image src="/bg.jpg" alt="Background" fill
style={{ objectFit: 'cover' }} />
</div>
// next.config.js — allow external domains
images: {
remotePatterns: [
{ protocol: 'https', hostname: 'cdn.example.com' },
],
},
// next/font — zero layout shift, self-hosted
import { Inter, Fira_Code } from 'next/font/google';
const inter = Inter({ subsets: ['latin'] });
const firaCode = Fira_Code({
subsets: ['latin'],
variable: '--font-mono',
});
// In root layout
<body className={`${inter.className} ${firaCode.variable}`}>
// Local font
import localFont from 'next/font/local';
const myFont = localFont({ src: './fonts/MyFont.woff2' });
| Method | Context | Usage |
<Link> | Any component | Declarative client-side navigation with prefetching |
useRouter() | Client Component | Programmatic navigation: push, replace, back, refresh |
usePathname() | Client Component | Returns current pathname (e.g., /blog/hello) |
useSearchParams() | Client Component | Read query string parameters |
redirect() | Server Component / Action | Server-side redirect (throws internally) |
permanentRedirect() | Server Component / Action | 301 redirect |
import Link from 'next/link';
<Link href="/about">About</Link>
<Link href="/blog/hello" prefetch={false}>Post</Link>
<Link href={{ pathname: '/search', query: { q: 'nextjs' } }}>Search</Link>
// Programmatic navigation
'use client';
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
export default function Nav() {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const q = searchParams.get('q');
return (
<button onClick={() => router.push('/dashboard')}>Go</button>
);
}
// Server-side redirect
import { redirect } from 'next/navigation';
export default async function Page() {
const session = await getSession();
if (!session) redirect('/login');
// ...
}
| Cache Layer | Where | Opt Out |
| Request Memoization | Server (per render) | Use AbortController signal |
| Data Cache | Server (persistent) | cache: 'no-store' or revalidate: 0 |
| Full Route Cache | Server (build time) | dynamic = 'force-dynamic' |
| Router Cache | Client (session) | router.refresh() or revalidatePath |
// Opt out of all caching for a page
export const dynamic = 'force-dynamic';
// Unstable cache for non-fetch data (e.g., DB queries)
import { unstable_cache } from 'next/cache';
const getCachedUser = unstable_cache(
async (id: string) => db.user.findUnique({ where: { id } }),
['user-cache'], // cache key parts
{ revalidate: 3600, tags: ['users'] }
);
| Command | Description |
npx create-next-app@latest my-app | Scaffold a new Next.js project with App Router |
npx create-next-app@latest --ts --tailwind --app | TypeScript + Tailwind + App Router preset |
next dev | Start development server (default port 3000) |
next dev --turbopack | Dev server with Turbopack (faster HMR) |
next build | Create production build |
next start | Start production server |
next lint | Run ESLint on the project |
next info | Print system info for bug reports |
| File | Loaded When |
.env | All environments |
.env.local | All environments (gitignored) |
.env.development | next dev only |
.env.production | next build / next start |
# .env.local
DATABASE_URL=postgres://localhost:5432/mydb # Server only
NEXT_PUBLIC_API_URL=https://api.example.com # Exposed to browser
# Access in code
// Server Component / Route Handler / Server Action
const db = process.env.DATABASE_URL;
// Client Component — only NEXT_PUBLIC_ prefix is available
const api = process.env.NEXT_PUBLIC_API_URL;
// app/api/posts/route.ts
import { NextRequest, NextResponse } from 'next/server';
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const page = searchParams.get('page') ?? '1';
const posts = await db.post.findMany({ take: 10, skip: (+page - 1) * 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 });
}
// Dynamic route handler: app/api/posts/[id]/route.ts
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
await db.post.delete({ where: { id } });
return new NextResponse(null, { status: 204 });
}
What is the difference between a layout and a template in Next.js?
A layout wraps child routes and preserves state across navigations -- it does not remount when you navigate between sibling routes. A template also wraps child routes, but it creates a new instance on every navigation, resetting all state and effects. Use layouts for persistent UI like sidebars and navigation bars, and templates when you need fresh state on each page transition (e.g., enter/exit animations, per-page analytics).
When should I use a Server Component vs a Client Component?
Use Server Components (the default in the App Router) for data fetching, accessing backend resources, and rendering non-interactive UI. Use Client Components (with the 'use client' directive) when you need interactivity: event handlers, useState, useEffect, browser APIs, or third-party libraries that depend on the DOM. Keep Client Components as leaf nodes to minimize the JavaScript sent to the browser.
How does caching work in Next.js App Router?
Next.js has four caching layers: (1) Request Memoization deduplicates identical fetch calls within a single server render. (2) The Data Cache persists fetch results across requests on the server. (3) Full Route Cache stores rendered HTML and RSC payloads at build time for static routes. (4) The Router Cache stores RSC payloads on the client for instant back/forward navigation. Opt out with cache: 'no-store', revalidatePath(), or revalidateTag().
What is the difference between redirect() and useRouter().push()?
redirect() is a server-side function for use in Server Components, Server Actions, and Route Handlers. It throws internally to trigger the redirect before any HTML is sent. useRouter().push() is a client-side hook that performs navigation without a full page reload. Use redirect() for server logic like auth checks, and useRouter().push() for client-side interactions.