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 Conventions

FilePurpose
page.tsxDefines a route's UI. Required to make a route publicly accessible.
layout.tsxShared UI that wraps child routes. Preserves state across navigations.
loading.tsxInstant loading UI shown while the route segment loads (uses React Suspense).
error.tsxError boundary for a route segment. Must be a Client Component.
not-found.tsxUI shown when notFound() is called or no matching route exists.
route.tsAPI Route Handler. Supports GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS.
template.tsxLike layout but creates a new instance on each navigation (state resets).
default.tsxFallback UI for parallel routes when the slot has no matching content.
global-error.tsxRoot-level error boundary. Wraps the entire <html> tag.
middleware.tsRuns 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

Routing

PatternFolderMatches
Staticapp/about/page.tsx/about
Dynamicapp/blog/[slug]/page.tsx/blog/hello-world
Catch-allapp/docs/[...slug]/page.tsx/docs/a, /docs/a/b/c
Optional catch-allapp/docs/[[...slug]]/page.tsx/docs, /docs/a/b
Route groupapp/(marketing)/about/page.tsx/about (parentheses ignored in URL)
Parallel routeapp/@modal/login/page.tsxNamed slot rendered alongside siblings
Intercepting routeapp/@modal/(.)photo/[id]/page.tsxIntercepts /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 }));
}

Server Components vs Client Components

FeatureServer Component (default)Client Component
DirectiveNone needed'use client' at top of file
Data fetchingasync/await directly in componentuseEffect, SWR, React Query
State / EffectsNot availableuseState, useEffect, etc.
Event handlersNot availableonClick, onChange, etc.
Browser APIsNot availablewindow, document, localStorage
Secrets / env varsSafe to use (server only)Only NEXT_PUBLIC_ vars
Bundle sizeZero JS sent to clientIncluded 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>;
}

Data Fetching

// 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

Server Actions

// 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>;
}

Metadata API

// 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

// 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).*)'],
};

Image and Font

// 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' });

Navigation

MethodContextUsage
<Link>Any componentDeclarative client-side navigation with prefetching
useRouter()Client ComponentProgrammatic navigation: push, replace, back, refresh
usePathname()Client ComponentReturns current pathname (e.g., /blog/hello)
useSearchParams()Client ComponentRead query string parameters
redirect()Server Component / ActionServer-side redirect (throws internally)
permanentRedirect()Server Component / Action301 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');
  // ...
}

Caching

Cache LayerWhereOpt Out
Request MemoizationServer (per render)Use AbortController signal
Data CacheServer (persistent)cache: 'no-store' or revalidate: 0
Full Route CacheServer (build time)dynamic = 'force-dynamic'
Router CacheClient (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'] }
);

CLI Commands

CommandDescription
npx create-next-app@latest my-appScaffold a new Next.js project with App Router
npx create-next-app@latest --ts --tailwind --appTypeScript + Tailwind + App Router preset
next devStart development server (default port 3000)
next dev --turbopackDev server with Turbopack (faster HMR)
next buildCreate production build
next startStart production server
next lintRun ESLint on the project
next infoPrint system info for bug reports

Environment Variables

FileLoaded When
.envAll environments
.env.localAll environments (gitignored)
.env.developmentnext dev only
.env.productionnext 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;

Route Handlers (API Routes)

// 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 });
}

Frequently Asked Questions

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.