Next.js
Requires Next.js 15+ / React 19 / App Router
Table of Contents
- File Conventions & Routing
- Server / Client Components
- Data Fetching
- Server Actions (Mutations)
- Navigation
- SearchParams
- Error Handling
- Metadata
- Route Handlers (API)
- Environment Variables
1. File Conventions & Routing
Special Files
| File | Role |
|---|---|
page.tsx | Route UI (the publicly accessible page) |
layout.tsx | Shared UI for all pages beneath it. Does not re-render on navigation |
template.tsx | Same as layout.tsx but re-created on every navigation |
loading.tsx | Fallback UI while the page is loading (powered by Suspense) |
error.tsx | Fallback UI on error (must be a Client Component) |
not-found.tsx | 404 UI |
route.ts | API endpoint (Route Handler) |
global-error.tsx | Error UI for the root layout |
layout.tsxpreserves state across navigations. Usetemplate.tsxwhen you need to reset state on every navigation (e.g. resetting a form).
Directory Structure & Routing
Only directories that contain a page.tsx are exposed as URLs.
app/
├── layout.tsx → / (shared layout for all pages)
├── page.tsx → /
├── blog/
│ ├── layout.tsx → shared layout for /blog and below
│ ├── page.tsx → /blog
│ └── [slug]/
│ ├── page.tsx → /blog/:slug
│ └── not-found.tsx
└── api/
└── posts/
└── route.ts → /api/posts (GET / POST etc.)
Dynamic Routing Variants
| Syntax | Matched URL Example | params Type |
|---|---|---|
[slug] | /blog/hello | { slug: string } |
[...slug] | /blog/a/b/c (1+ segments) | { slug: string[] } |
[[...slug]] | /blog or /blog/a/b (0+ segments) | { slug?: string[] } |
// app/blog/[...slug]/page.tsx
type Props = {
params: Promise<{ slug: string[] }>
}
export default async function Page({ params }: Props) {
const { slug } = await params
// /blog/a/b/c → slug = ["a", "b", "c"]
}Route Groups ( )
Wrapping a folder name in ( ) has no effect on the URL but lets you organize routes logically.
Useful when you want a different layout.tsx per group.
app/
├── (public)/
│ ├── page.tsx → /
│ └── about/
│ └── page.tsx → /about
└── (admin)/
├── layout.tsx ← admin-only layout (no "(admin)" in URL)
├── dashboard/
│ └── page.tsx → /dashboard
└── settings/
└── page.tsx → /settings
Splitting layouts with Route Groups is handy when the header/sidebar differs significantly between authenticated and unauthenticated areas.
Root Layout (Required)
// app/layout.tsx
type Props = {
children: React.ReactNode
}
export default function RootLayout({ children }: Props) {
return (
<html lang="en">
<body>{children}</body>
</html>
)
}Dynamic Route (params is a Promise since v15)
// app/blog/[slug]/page.tsx
type Props = {
params: Promise<{ slug: string }>
}
export default async function Page({ params }: Props) {
const { slug } = await params
const post = await getPost(slug)
return <div>{post.title}</div>
}
// Specify paths to statically generate at build time
export async function generateStaticParams() {
const posts = await getPosts()
return posts.map((post) => ({ slug: post.slug }))
}2. Server / Client Components
All components are Server Components by default.
When to Use Each
| Use Server Component | Use Client Component |
|---|---|
| Fetching data from DB / API | useState / useEffect and other hooks |
| Accessing secrets (API keys, etc.) | Click and other event handlers |
| Reducing JS bundle size | Browser APIs (localStorage, etc.) |
Client Component
Add 'use client' at the top of the file.
// app/ui/counter.tsx
'use client'
import { useState } from 'react'
export default function Counter() {
const [count, setCount] = useState(0)
return <button onClick={() => setCount(count + 1)}>{count}</button>
}Passing Data from Server to Client
Fetch data in a Server Component and pass it to a Client Component as props.
// app/page.tsx (Server Component)
import LikeButton from '@/app/ui/like-button'
type Props = {
params: Promise<{ id: string }>
}
export default async function Page({ params }: Props) {
const { id } = await params
const post = await getPost(id)
return <LikeButton likes={post.likes} />
}// app/ui/like-button.tsx (Client Component)
'use client'
type Props = {
likes: number
}
export default function LikeButton({ likes }: Props) {
const [count, setCount] = useState(likes)
return <button onClick={() => setCount(c => c + 1)}>Like {count}</button>
}Context Provider Pattern
Context cannot be used in Server Components, so wrap with a 'use client' provider.
// app/providers/theme-provider.tsx
'use client'
import { createContext, useContext, useState } from 'react'
type Theme = 'light' | 'dark'
const ThemeContext = createContext<Theme>('light')
type Props = {
children: React.ReactNode
}
export function ThemeProvider({ children }: Props) {
const [theme, setTheme] = useState<Theme>('light')
return <ThemeContext.Provider value={theme}>{children}</ThemeContext.Provider>
}
export const useTheme = () => useContext(ThemeContext)// app/layout.tsx (remains a Server Component)
import { ThemeProvider } from './providers/theme-provider'
type Props = {
children: React.ReactNode
}
export default function RootLayout({ children }: Props) {
return (
<html>
<body>
<ThemeProvider>{children}</ThemeProvider>
</body>
</html>
)
}3. Data Fetching
fetch in a Server Component
// app/blog/page.tsx
export default async function Page() {
const res = await fetch('https://api.example.com/posts')
if (!res.ok) return <p>Failed to load</p>
const posts: Post[] = await res.json()
return (
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)
}
fetchresponses are not cached by default (changed in v15).
Parallel Data Fetching
// Chaining awaits is sequential — use Promise.all for parallel fetching
type Props = {
params: Promise<{ username: string }>
}
export default async function Page({ params }: Props) {
const { username } = await params
const [artist, albums] = await Promise.all([
getArtist(username),
getAlbums(username),
])
return (
<>
<h1>{artist.name}</h1>
<Albums list={albums} />
</>
)
}Streaming (loading.tsx / <Suspense>)
// app/blog/loading.tsx → shown automatically while navigating to /blog
export default function Loading() {
return <p>Loading...</p>
}// app/blog/page.tsx (fine-grained control with Suspense)
import { Suspense } from 'react'
import BlogList from '@/components/BlogList'
export default function Page() {
return (
<div>
<h1>Blog</h1>
{/* Show fallback until BlogList's fetch completes */}
<Suspense fallback={<p>Loading articles...</p>}>
<BlogList />
</Suspense>
</div>
)
}React.cache for Deduplication
Useful when both generateMetadata and the page body need the same data.
// app/lib/data.ts
import { cache } from 'react'
export const getPost = cache(async (slug: string) => {
const res = await fetch(`https://api.example.com/posts/${slug}`)
return res.json() as Promise<Post>
})4. Server Actions (Mutations)
Defining a Server Action ('use server')
// app/lib/actions.ts
'use server'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
export async function createPost(formData: FormData) {
const title = formData.get('title') as string
await db.posts.create({ title }) // write to DB
revalidatePath('/posts') // invalidate cache and re-fetch
redirect('/posts') // redirect after completion
}Calling from a Form
// app/ui/new-post-form.tsx
import { createPost } from '@/app/lib/actions'
export function NewPostForm() {
return (
<form action={createPost}>
<input type="text" name="title" placeholder="Title" required />
<button type="submit">Post</button>
</form>
)
}Managing Errors / Pending State with useActionState
// app/ui/new-post-form.tsx
'use client'
import { useActionState } from 'react'
import { createPost } from '@/app/lib/actions'
type State = { message: string }
export function NewPostForm() {
const [state, formAction, pending] = useActionState<State, FormData>(
createPost,
{ message: '' }
)
return (
<form action={formAction}>
<input type="text" name="title" required />
{state.message && <p role="alert">{state.message}</p>}
<button disabled={pending}>
{pending ? 'Submitting...' : 'Post'}
</button>
</form>
)
}// app/lib/actions.ts (returning errors)
'use server'
export async function createPost(
prevState: { message: string },
formData: FormData
) {
const title = formData.get('title') as string
if (!title) return { message: 'Title is required' }
await db.posts.create({ title })
revalidatePath('/posts')
redirect('/posts')
}Calling from an Event Handler
'use client'
import { useState } from 'react'
import { incrementLike } from '@/app/lib/actions'
type Props = {
initialLikes: number
}
export default function LikeButton({ initialLikes }: Props) {
const [likes, setLikes] = useState(initialLikes)
return (
<button
onClick={async () => {
const updated = await incrementLike()
setLikes(updated)
}}
>
Like {likes}
</button>
)
}cookies / headers (async since v15)
// app/lib/actions.ts
'use server'
import { cookies, headers } from 'next/headers'
export async function exampleAction() {
const cookieStore = await cookies()
const headersList = await headers()
cookieStore.get('token')?.value // read
cookieStore.set('token', 'abc123') // set
cookieStore.delete('token') // delete
headersList.get('user-agent') // read header
}5. Navigation
<Link> Component
Links entering the viewport are automatically prefetched.
import Link from 'next/link'
// Basic
<Link href="/blog">Blog</Link>
// Dynamic route
<Link href={`/blog/${post.slug}`}>{post.title}</Link>
// Disable prefetch
<Link href="/dashboard" prefetch={false}>Dashboard</Link>
// Active link styling (combine with usePathname)useRouter (Client Component)
Use for programmatic navigation.
'use client'
import { useRouter } from 'next/navigation'
export default function BackButton() {
const router = useRouter()
return (
<>
<button onClick={() => router.push('/dashboard')}>Go to Dashboard</button>
<button onClick={() => router.back()}>Back</button>
<button onClick={() => router.refresh()}>Refresh</button>
</>
)
}redirect (Server Component / Server Action)
import { redirect } from 'next/navigation'
export default async function Page() {
const session = await getSession()
if (!session) redirect('/login') // ← code below this line does not run
// ...
}usePathname / useSearchParams (Client Component)
'use client'
import { usePathname, useSearchParams } from 'next/navigation'
type Props = {
href: string
label: string
}
export default function NavLink({ href, label }: Props) {
const pathname = usePathname()
const isActive = pathname === href
return (
<Link href={href} className={isActive ? 'font-bold' : ''}>
{label}
</Link>
)
}6. SearchParams
Read and update URL query parameters (?q=hello&page=2, etc.).
Reading in a Server Component (async since v15)
// app/search/page.tsx
type Props = {
searchParams: Promise<{ q?: string; page?: string }>
}
export default async function Page({ searchParams }: Props) {
const { q = '', page = '1' } = await searchParams
const results = await searchPosts(q, Number(page))
return <SearchResults results={results} />
}Reading in a Client Component (useSearchParams)
'use client'
import { useSearchParams } from 'next/navigation'
export default function SearchInput() {
const searchParams = useSearchParams()
const q = searchParams.get('q') ?? ''
return <input defaultValue={q} placeholder="Search..." />
}
useSearchParamsmay cause a build error if not wrapped in<Suspense>.
Updating the URL (useRouter + usePathname)
Use URLSearchParams to build the URL when rewriting query strings.
'use client'
import { useRouter, usePathname, useSearchParams } from 'next/navigation'
export default function SearchForm() {
const router = useRouter()
const pathname = usePathname()
const searchParams = useSearchParams()
function handleSearch(term: string) {
const params = new URLSearchParams(searchParams.toString())
if (term) {
params.set('q', term)
} else {
params.delete('q')
}
params.set('page', '1') // reset to page 1 on new search
router.replace(`${pathname}?${params.toString()}`)
}
return (
<input
defaultValue={searchParams.get('q') ?? ''}
onChange={(e) => handleSearch(e.target.value)}
placeholder="Search..."
/>
)
}7. Error Handling
error.tsx (Must Be a Client Component)
// app/dashboard/error.tsx
'use client'
import { useEffect } from 'react'
type Props = {
error: Error & { digest?: string }
reset: () => void
}
export default function ErrorPage({ error, reset }: Props) {
useEffect(() => {
console.error(error)
}, [error])
return (
<div>
<h2>Something went wrong</h2>
<button onClick={reset}>Try again</button>
</div>
)
}not-found.tsx and notFound()
// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation'
type Props = {
params: Promise<{ slug: string }>
}
export default async function Page({ params }: Props) {
const { slug } = await params
const post = await getPost(slug)
if (!post) notFound() // → renders the nearest not-found.tsx
return <div>{post.title}</div>
}// app/blog/[slug]/not-found.tsx
export default function NotFound() {
return <div>404 - Post not found</div>
}8. Metadata
Static Metadata
// app/blog/layout.tsx
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'Blog',
description: 'Latest articles',
openGraph: {
title: 'Blog',
description: 'Latest articles',
images: [{ url: '/og-image.png' }],
},
}
type Props = {
children: React.ReactNode
}
export default function Layout({ children }: Props) {
return children
}Dynamic Metadata
// app/blog/[slug]/page.tsx
import type { Metadata } from 'next'
import { cache } from 'react'
// Share data between generateMetadata and the page body via React.cache
const getPost = cache(async (slug: string) => {
const res = await fetch(`https://api.example.com/posts/${slug}`)
return res.json() as Promise<Post>
})
type Props = {
params: Promise<{ slug: string }>
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params
const post = await getPost(slug)
return {
title: post.title,
description: post.description,
}
}
export default async function Page({ params }: Props) {
const { slug } = await params
const post = await getPost(slug) // cached — no duplicate fetch
return <article>{post.title}</article>
}9. Route Handlers (API)
Create a route.ts file under app/api/.
// app/api/posts/route.ts
import { NextRequest } from 'next/server'
export async function GET(request: NextRequest) {
const q = request.nextUrl.searchParams.get('q')
const posts = await getPosts(q)
return Response.json(posts)
}
export async function POST(request: NextRequest) {
const body = await request.json()
const post = await createPost(body)
return Response.json(post, { status: 201 })
}Dynamic Route Handler
// app/api/posts/[id]/route.ts
type RouteContext = {
params: Promise<{ id: string }>
}
export async function GET(_request: Request, { params }: RouteContext) {
const { id } = await params
const post = await getPost(id)
if (!post) return new Response('Not Found', { status: 404 })
return Response.json(post)
}
export async function DELETE(_request: Request, { params }: RouteContext) {
const { id } = await params
await deletePost(id)
return new Response(null, { status: 204 })
}Server Actions vs Route Handlers
| Item | Server Actions | Route Handlers |
|---|---|---|
| Primary use | Form handling, DB writes tightly coupled to UI | REST API, externally exposed endpoints |
| How to call | Import and call directly as a function | Send an HTTP request via fetch |
| External access | Generally not accessible externally | Accessible (e.g. Webhooks from services) |
| Auth / header control | Handled implicitly on the server | Controlled explicitly via Request object |
| Read data (GET) | Not recommended (use Server Component directly) | Common |
Decision guide
- Form operations / DB writes inside the app → Server Actions
- Fetching data from CSR (Client Component) via
fetch→ Route Handlers - Exposing an API to external services / receiving Webhooks → Route Handlers
10. Environment Variables
# .env.local (exclude from version control)
DATABASE_URL="postgres://..." # server-side only
NEXT_PUBLIC_API_URL="https://..." # exposed to the client as well// Server-side only (Server Component / Route Handler / Server Action)
process.env.DATABASE_URL
// Available on the client (NEXT_PUBLIC_ prefix required)
process.env.NEXT_PUBLIC_API_URLVariables without the
NEXT_PUBLIC_prefix return an empty string when accessed on the client.
Tips
Optimize Images with next/image
import Image from 'next/image'
<Image
src="/hero.png"
alt="Hero image"
width={1200}
height={630}
priority // set for LCP images (large images shown first)
/>Optimize Fonts with next/font
// app/layout.tsx
import { Inter } from 'next/font/google'
const inter = Inter({
subsets: ['latin'],
weight: ['400', '700'],
})
type Props = {
children: React.ReactNode
}
export default function RootLayout({ children }: Props) {
return (
<html lang="en" className={inter.className}>
<body>{children}</body>
</html>
)
}Key Changes in v15
| Change | Up to v14 | v15 and Later |
|---|---|---|
params / searchParams | Synchronous | Asynchronous (await required) |
cookies() / headers() | Synchronous | Asynchronous (await required) |
fetch caching | Cached by default | Not cached by default |