Next.js is a React framework that adds file-based routing, server-side rendering, static generation, and full-stack capabilities. This reference covers the App Router (Next.js 13+) with practical patterns for everything you need day-to-day.
Quick reference
| Task | Pattern |
|---|---|
| Create a page | app/dashboard/page.tsx → /dashboard |
| Shared layout | app/dashboard/layout.tsx |
| Dynamic route | app/posts/[slug]/page.tsx |
| Catch-all route | app/docs/[...slug]/page.tsx |
| API route | app/api/users/route.ts → GET/POST |
| Server Component | Default — no 'use client' directive |
| Client Component | Add 'use client' at top of file |
| Fetch with cache | fetch(url, { cache: 'force-cache' }) |
| Fetch no cache | fetch(url, { cache: 'no-store' }) |
| Revalidate ISR | fetch(url, { next: { revalidate: 60 } }) |
| Server Action | async function save() { 'use server'; ... } |
| Navigate | <Link href="/about">About</Link> |
| Programmatic nav | const router = useRouter(); router.push('/x') |
| Redirect (server) | import { redirect } from 'next/navigation' |
| Not found | import { notFound } from 'next/navigation' |
| Metadata | export const metadata = { title: '...' } |
| Loading UI | app/dashboard/loading.tsx |
| Error UI | app/dashboard/error.tsx |
| Optimise image | <Image src={img} alt="..." width={800} height={600} /> |
| Env variable | process.env.MY_SECRET / NEXT_PUBLIC_* for client |
| Parallel routes | app/@modal/(.)post/[id]/page.tsx |
Project structure
my-app/
├── app/ # App Router root
│ ├── layout.tsx # Root layout (required)
│ ├── page.tsx # Homepage /
│ ├── globals.css
│ ├── dashboard/
│ │ ├── layout.tsx # Nested layout
│ │ ├── page.tsx # /dashboard
│ │ ├── loading.tsx # Suspense loading UI
│ │ └── error.tsx # Error boundary
│ └── api/
│ └── users/
│ └── route.ts # API route handler
├── components/ # Shared components
├── lib/ # Utilities, db clients
├── public/ # Static assets
└── next.config.ts
Routing
File conventions
| File | Purpose |
|---|---|
page.tsx |
Unique UI for a route — makes it publicly accessible |
layout.tsx |
Shared UI that wraps child pages, persists across navigation |
loading.tsx |
Instant loading state (wraps page in <Suspense>) |
error.tsx |
Error boundary (must be a Client Component) |
not-found.tsx |
Custom 404 for a segment |
route.ts |
API endpoint (no UI) |
middleware.ts |
Runs before every request (auth, redirects) |
Dynamic routes
// app/posts/[slug]/page.tsx
export default async function PostPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params; // Next.js 15: params is a Promise
const post = await getPost(slug);
if (!post) notFound();
return <article>{post.title}</article>;
}
// Catch-all: app/docs/[...slug]/page.tsx
// Matches /docs/a, /docs/a/b, /docs/a/b/c
// params.slug = ['a'] | ['a','b'] | ['a','b','c']
// Optional catch-all: app/docs/[[...slug]]/page.tsx
// Also matches /docs itself
generateStaticParams
// Pre-render dynamic routes at build time
export async function generateStaticParams() {
const posts = await fetchAllPosts();
return posts.map((post) => ({ slug: post.slug }));
}
Layouts
Root layout (required)
// app/layout.tsx
import type { Metadata } from 'next';
import './globals.css';
export const metadata: Metadata = {
title: { template: '%s | MySite', default: 'MySite' },
description: 'My awesome site',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
Nested layout
// app/dashboard/layout.tsx
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="flex">
<nav>/* sidebar */</nav>
<main>{children}</main>
</div>
);
}
Server vs Client Components
Server Components (default)
No 'use client' — runs only on the server:
// app/users/page.tsx — Server Component
async function getUsers() {
const res = await fetch('https://api.example.com/users', {
cache: 'no-store', // always fresh
});
return res.json();
}
export default async function UsersPage() {
const users = await getUsers(); // direct async/await
return (
<ul>
{users.map((u: { id: number; name: string }) => (
<li key={u.id}>{u.name}</li>
))}
</ul>
);
}
Can do: database queries, file system, secrets, async/await at component level.
Cannot do: useState, useEffect, event handlers, browser APIs.
Client Components
Add 'use client' at the top:
'use client';
import { useState } from 'react';
export function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}
Can do: hooks, event handlers, browser APIs, useRouter, useSearchParams.
Cannot do: direct database access, async component function.
Mixing them
Pass Server Component data as props to a Client Component:
// Server Component
import { LikeButton } from './LikeButton'; // Client Component
export default async function Post({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const post = await db.post.findUnique({ where: { id } });
return (
<article>
<h1>{post.title}</h1>
<LikeButton postId={id} initialLikes={post.likes} />
</article>
);
}
Data fetching
fetch with caching
// Static — cached indefinitely (default in Next.js 14, opt-in in 15)
const data = await fetch('https://api.example.com/data', {
cache: 'force-cache',
});
// Dynamic — never cached, always fresh
const data = await fetch('https://api.example.com/data', {
cache: 'no-store',
});
// ISR — revalidate every 60 seconds
const data = await fetch('https://api.example.com/data', {
next: { revalidate: 60 },
});
// Tag-based revalidation
const data = await fetch('https://api.example.com/posts', {
next: { tags: ['posts'] },
});
// Invalidate from a Server Action or route handler:
import { revalidateTag } from 'next/cache';
revalidateTag('posts');
Database / ORM access (Prisma example)
import { db } from '@/lib/db';
// app/posts/page.tsx — Server Component
export default async function PostsPage() {
const posts = await db.post.findMany({
orderBy: { createdAt: 'desc' },
take: 10,
});
return <PostList posts={posts} />;
}
Parallel data fetching
export default async function Dashboard() {
// Start both requests in parallel
const [user, orders] = await Promise.all([
fetchUser(),
fetchOrders(),
]);
return <DashboardView user={user} orders={orders} />;
}
Navigation
Link component
import Link from 'next/link';
// Basic
<Link href="/about">About</Link>
// Dynamic
<Link href={`/posts/${post.slug}`}>Read post</Link>
// With query params
<Link href={{ pathname: '/search', query: { q: 'next.js' } }}>
Search
</Link>
// Replace instead of push (no back history entry)
<Link href="/login" replace>Log in</Link>
// Prefetch control (default: true on hover)
<Link href="/heavy-page" prefetch={false}>Heavy page</Link>
Programmatic navigation (Client Component)
'use client';
import { useRouter } from 'next/navigation';
export function LoginButton() {
const router = useRouter();
async function handleLogin() {
await login();
router.push('/dashboard'); // navigate
router.replace('/dashboard'); // replace history
router.back(); // go back
router.refresh(); // re-fetch current route data
}
return <button onClick={handleLogin}>Log in</button>;
}
Redirect (Server)
import { redirect, notFound } from 'next/navigation';
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const item = await getItem(id);
if (!item) notFound(); // renders not-found.tsx
if (item.archived) redirect('/archive'); // 307 by default
return <ItemView item={item} />;
}
Server Actions
Server Actions are async functions that run on the server, called from forms or Client Components.
Form-based action
// app/posts/new/page.tsx
export default function NewPostPage() {
async function createPost(formData: FormData) {
'use server';
const title = formData.get('title') as string;
await db.post.create({ data: { title } });
redirect('/posts');
}
return (
<form action={createPost}>
<input name="title" placeholder="Post title" required />
<button type="submit">Create</button>
</form>
);
}
Separate actions file
// app/actions/posts.ts
'use server';
import { revalidatePath } from 'next/cache';
import { db } from '@/lib/db';
export async function createPost(formData: FormData) {
const title = formData.get('title') as string;
if (!title) throw new Error('Title is required');
await db.post.create({ data: { title } });
revalidatePath('/posts'); // invalidate cached route
}
export async function deletePost(id: string) {
await db.post.delete({ where: { id } });
revalidatePath('/posts');
}
Calling a Server Action from a Client Component
'use client';
import { useState } from 'react';
import { createPost } from '@/app/actions/posts';
export function CreatePostForm() {
const [pending, setPending] = useState(false);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setPending(true);
const formData = new FormData(e.currentTarget);
await createPost(formData);
setPending(false);
}
return (
<form onSubmit={handleSubmit}>
<input name="title" disabled={pending} />
<button disabled={pending}>{pending ? 'Saving…' : 'Save'}</button>
</form>
);
}
useActionState (Next.js 15 / React 19)
'use client';
import { useActionState } from 'react';
import { createPost } from '@/app/actions/posts';
export function PostForm() {
const [state, action, pending] = useActionState(createPost, null);
return (
<form action={action}>
{state?.error && <p className="error">{state.error}</p>}
<input name="title" />
<button disabled={pending}>{pending ? 'Saving…' : 'Save'}</button>
</form>
);
}
API routes (Route Handlers)
// app/api/posts/route.ts
import { NextRequest, NextResponse } from 'next/server';
export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl;
const page = Number(searchParams.get('page') ?? '1');
const posts = await db.post.findMany({ skip: (page - 1) * 10, take: 10 });
return NextResponse.json({ posts });
}
export async function POST(request: NextRequest) {
const body = await request.json();
if (!body.title) {
return NextResponse.json({ error: 'Title required' }, { status: 400 });
}
const post = await db.post.create({ data: { title: body.title } });
return NextResponse.json(post, { status: 201 });
}
// 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 });
}
Loading and error states
loading.tsx — instant loading UI
// app/dashboard/loading.tsx
export default function Loading() {
return (
<div className="flex items-center justify-center h-screen">
<div className="spinner" />
</div>
);
}
// Next.js wraps page.tsx in <Suspense fallback={<Loading />}> automatically
error.tsx — error boundary
// app/dashboard/error.tsx
'use client'; // Error components must be Client Components
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<div>
<h2>Something went wrong!</h2>
<p>{error.message}</p>
<button onClick={reset}>Try again</button>
</div>
);
}
Streaming with Suspense
import { Suspense } from 'react';
export default function Page() {
return (
<>
<h1>Dashboard</h1>
<Suspense fallback={<p>Loading stats…</p>}>
<SlowStats /> {/* fetches its own data */}
</Suspense>
</>
);
}
Metadata
Static metadata
// app/about/page.tsx
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'About Us',
description: 'Learn more about our team.',
openGraph: {
title: 'About Us',
description: 'Learn more about our team.',
images: ['/og-about.png'],
},
};
Dynamic metadata
// app/posts/[slug]/page.tsx
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.coverImage] },
};
}
Image optimisation
import Image from 'next/image';
// Local image — Next.js auto-detects width/height
import profilePic from '@/public/profile.jpg';
<Image src={profilePic} alt="Profile" />
// Remote image — explicit width+height required
<Image
src="https://cdn.example.com/photo.jpg"
alt="Photo"
width={800}
height={600}
quality={85}
priority // load eagerly (above the fold)
/>
// Fill parent container
<div style={{ position: 'relative', height: 400 }}>
<Image src="/banner.jpg" alt="Banner" fill objectFit="cover" />
</div>
Allow remote image domains in next.config.ts:
const config: NextConfig = {
images: {
remotePatterns: [
{ protocol: 'https', hostname: 'cdn.example.com' },
],
},
};
Environment variables
| Variable | Available on |
|---|---|
MY_SECRET |
Server only (never sent to browser) |
NEXT_PUBLIC_API_URL |
Server and client (bundled into JS) |
# .env.local (gitignored)
DATABASE_URL=postgresql://...
NEXT_PUBLIC_API_URL=https://api.example.com
// Server Component / Server Action / API route
const dbUrl = process.env.DATABASE_URL; // ✓
// Client Component
const apiUrl = process.env.NEXT_PUBLIC_API_URL; // ✓ (NEXT_PUBLIC_ prefix)
const secret = process.env.MY_SECRET; // ✗ undefined on client
Middleware
// middleware.ts (project root, not inside app/)
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
const token = request.cookies.get('token');
if (!token && request.nextUrl.pathname.startsWith('/dashboard')) {
return NextResponse.redirect(new URL('/login', request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ['/dashboard/:path*', '/api/protected/:path*'],
};
Common mistakes
| Mistake | What happens | Fix |
|---|---|---|
Using useState/useEffect in a Server Component |
Build error: hooks not supported | Add 'use client' to that component |
Accessing process.env.SECRET in a Client Component |
undefined at runtime |
Prefix with NEXT_PUBLIC_ or fetch it server-side |
params not awaited (Next.js 15) |
TypeScript error / runtime warning | const { id } = await params |
| Importing a Server Component from a Client Component | Build error | Move Server Component data fetching up, pass data as props |
Using fetch without cache options in dynamic routes |
Stale data or unnecessary requests | Set cache: 'no-store' for dynamic, revalidate for ISR |
Forgetting 'use server' in action files |
Function runs on client — security leak | Add 'use server' at the top of every action file |
Putting secrets in NEXT_PUBLIC_ vars |
Secret exposed in browser bundle | Use plain MY_SECRET for server-only values |
revalidatePath / revalidateTag called inside a try/catch that swallows errors |
Cache not invalidated silently | Call them after successful mutation, outside catch |
FAQ
App Router vs Pages Router — which should I use?
App Router (the app/ directory, Next.js 13+) is the current default and recommended for all new projects. It enables Server Components, nested layouts, and Server Actions. Pages Router (pages/) still works and is supported, but won't receive new features.
How do I fetch data on the client?
Use a Client Component with useEffect + fetch, or a library like SWR or TanStack Query. For most reads, prefer a Server Component so the fetch happens on the server — faster, SEO-friendly, and secrets stay safe.
What's the difference between revalidatePath and revalidateTag?revalidatePath('/posts') purges the cache for a specific URL. revalidateTag('posts') purges all fetch() calls tagged with { next: { tags: ['posts'] } }, regardless of which page they're on. Tags are more precise for shared data.
How do I share state between pages?
For UI state: React Context in a Client Component provider (put it in layout.tsx). For server-side data: re-fetch in each Server Component — it's cached automatically. For global app state: Zustand or Jotai work well, both support React 19.
When should I use a Server Action vs an API route?
Server Actions are simpler for form submissions and mutations within your Next.js app. API routes (route.ts) are better when you need an endpoint consumed by external clients, mobile apps, or third-party webhooks.
How do I handle authentication?
NextAuth.js / Auth.js is the most popular solution — it handles OAuth, credentials, and JWT/database sessions. For fine-grained control, use Middleware to protect routes, and read the session in Server Components or Server Actions via a getSession() helper.