Skip to main content
Navigation
HomeTechnical ReferenceJournalGitHubGitHub
Sidebar — toggle document categories via the logo
Categories

Next.js

Overview

Next.js is the React framework for building web applications with server-side rendering, static generation, API routes, and edge functions. Version 14+ introduced the App Router with React Server Components as the default, fundamentally changing how data flows between client and server.

Project setup

create-next-app scaffolds a project with TypeScript, Tailwind, ESLint, and the App Router preconfigured. The generated next.config.ts centralizes options like allowed image domains, redirects, and security headers in one typed file.

npx create-next-app@latest my-app --typescript --tailwind --eslint --app --src-dir

# Or with pnpm
pnpm create next-app my-app --typescript --tailwind --eslint --app --src-dir
// next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
// Enable React strict mode
reactStrictMode: true,

// Image optimization
images: {
remotePatterns: [
{ protocol: "https", hostname: "cdn.example.com" },
],
},

// Redirects
async redirects() {
return [
{ source: "/old-path", destination: "/new-path", permanent: true },
];
},

// Headers
async headers() {
return [
{
source: "/api/:path*",
headers: [
{ key: "Access-Control-Allow-Origin", value: "*" },
],
},
];
},
};

export default nextConfig;

App Router fundamentals

File conventions

The App Router is file-based: the folder structure under src/app/ maps directly to URLs, and each special filename has a fixed role. layout.tsx wraps everything below it, page.tsx defines a route's UI, and the loading/error/not-found files handle those states automatically.

src/app/
├── layout.tsx # Root layout (required)
├── page.tsx # Home page (/)
├── loading.tsx # Loading UI (shown during page load)
├── error.tsx # Error boundary
├── not-found.tsx # 404 page
├── global-error.tsx # Global error boundary
├── route.ts # API route handler

├── dashboard/
│ ├── layout.tsx # Dashboard layout (nested)
│ ├── page.tsx # /dashboard
│ ├── loading.tsx # Dashboard loading state
│ └── settings/
│ └── page.tsx # /dashboard/settings

└── api/
└── users/
└── route.ts # API endpoint: /api/users

Server Components (default)

All components in the App Router are Server Components by default. They render on the server, never send JavaScript to the client, and can directly access databases and filesystems.

// app/users/page.tsx — Server Component
import { db } from "@/lib/db";

export default async function UsersPage() {
const users = await db.user.findMany({ orderBy: { createdAt: "desc" } });

return (
<div>
<h1>Users</h1>
{users.map((user) => (
<div key={user.id}>{user.name}</div>
))}
</div>
);
}

Client Components

Add "use client" when you need interactivity — state, effects, event handlers, browser APIs.

"use client";

import { useState } from "react";

export function Counter() {
const [count, setCount] = useState(0);

return (
<button onClick={() => setCount((c) => c + 1)}>
Count: {count}
</button>
);
}

Layouts

Layouts wrap pages and persist across navigations — state is preserved when navigating between pages that share a layout.

// app/dashboard/layout.tsx
import { Sidebar } from "./sidebar";

export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="flex">
<Sidebar />
<main className="flex-1">{children}</main>
</div>
);
}

Data fetching

In Server Components

Because Server Components execute only on the server, they can query the database directly with no API layer in between — just import a client and await the query in an async component.

// Direct database access (Server Component — no API layer needed)
import { db } from "@/lib/db";

async function getPosts() {
return db.post.findMany({ include: { author: true } });
}

export default async function BlogPage() {
const posts = await getPosts();

return (
<ul>
{posts.map((post) => (
<li key={post.id}>
{post.title} by {post.author.name}
</li>
))}
</ul>
);
}

Fetch from external API

For third-party data, Next.js extends the global fetch with caching options. Passing next: { revalidate } opts into ISR: the route is regenerated at most once per hour instead of on every request, with the last generated result served in between.

async function getData() {
const res = await fetch("https://api.example.com/data", {
next: { revalidate: 3600 }, // ISR: revalidate every hour
});
if (!res.ok) throw new Error("Failed to fetch");
return res.json();
}

export default async function Page() {
const data = await getData();
return <pre>{JSON.stringify(data, null, 2)}</pre>;
}

Caching strategies

StrategyHowUse case
Static (default)fetch(url) — cached indefinitelyContent that never changes
ISRfetch(url, { next: { revalidate: 3600 } })Content that changes infrequently
Dynamicfetch(url, { cache: "no-store" })Real-time / per-request data
On-demandrevalidateTag("posts") in a route handlerManual cache invalidation

Revalidation

revalidateTag and revalidatePath purge cached data on demand. Call them from a route handler or webhook the moment data changes, so the cache is invalidated immediately instead of waiting for a time-based revalidate window.

// Route handler to purge cache on external event
// app/api/revalidate/route.ts
import { revalidateTag, revalidatePath } from "next/cache";
import { NextRequest } from "next/server";

export async function POST(request: NextRequest) {
const { tag } = await request.json();

revalidateTag(tag); // Invalidate by tag
revalidatePath("/blog"); // Invalidate by path

return Response.json({ revalidated: true });
}

Route handlers (API)

Route handlers in route.ts files replace the old pages/api directory. Export one function per HTTP method (GET, POST, ...) using the standard Request/Response types, and you get a full API endpoint with no extra framework.

// app/api/users/route.ts
import { NextRequest } from "next/server";

export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const page = searchParams.get("page") ?? "1";

const users = await db.user.findMany({
skip: (Number(page) - 1) * 10,
take: 10,
});

return Response.json({ users });
}

export async function POST(request: NextRequest) {
const body = await request.json();

const user = await db.user.create({ data: body });

return Response.json(user, { status: 201 });
}

Dynamic route params

For per-item endpoints, create a dynamic segment like [id] in the path. The params object is a Promise in the App Router — await it, then look up the record and return a 404 when it doesn't exist.

// app/api/users/[id]/route.ts
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params;
const user = await db.user.findUnique({ where: { id } });

if (!user) {
return Response.json({ error: "Not found" }, { status: 404 });
}

return Response.json(user);
}

Middleware

Middleware runs on the edge before a request reaches a route — ideal for auth redirects, host rewrites, and A/B testing. Keep it small and dependency-free; anything heavy belongs in the routes themselves.

// middleware.ts (at the project root, next to app/)
import { NextRequest, NextResponse } from "next/server";

export function middleware(request: NextRequest) {
const token = request.cookies.get("session");

// Require a session cookie for /dashboard routes
if (request.nextUrl.pathname.startsWith("/dashboard")) {
if (!token) {
return NextResponse.redirect(new URL("/login", request.url));
}
}

// Redirect www to apex
const hostname = request.headers.get("host") ?? "";
if (hostname.startsWith("www.")) {
return NextResponse.redirect(
new URL(request.nextUrl.pathname, `https://${hostname.slice(4)}`),
);
}

return NextResponse.next();
}

export const config = {
matcher: [
"/dashboard/:path*",
"/((?!api|_next/static|_next/image|favicon.ico).*)",
],
};

Server Actions

Server Actions let forms and client components invoke server functions directly — type-safe and without manual fetch calls. Mark a module "use server", and each exported async function becomes callable from the client, receiving serialized FormData when bound to a form's action.

// app/actions.ts
"use server";

import { revalidatePath } from "next/cache";
import { db } from "@/lib/db";
import { z } from "zod";

const schema = z.object({
name: z.string().min(1),
email: z.string().email(),
});

export async function createUser(formData: FormData) {
const parsed = schema.parse({
name: formData.get("name"),
email: formData.get("email"),
});

await db.user.create({ data: parsed });
revalidatePath("/users");
}
// Usage in a Client Component
"use client";
import { createUser } from "./actions";

export function CreateUserForm() {
return (
<form action={createUser}>
<input name="name" required />
<input name="email" type="email" required />
<button type="submit">Create</button>
</form>
);
}

Image optimization

next/image serves optimized, responsive images automatically — resizing, format conversion (WebP/AVIF), and lazy loading out of the box. Local images get their dimensions from the import; remote images require explicit width/height and an allowlist entry in next.config.ts.

import Image from "next/image";

// Local image (auto-width/height from import)
import hero from "@/public/hero.jpg";

export function Hero() {
return (
<Image
src={hero}
alt="Hero banner"
priority // Preload (above-the-fold images)
placeholder="blur"
quality={90}
/>
);
}

// Remote image (requires width/height + remotePatterns in config)
<Image
src="https://cdn.example.com/photo.jpg"
alt="Photo"
width={800}
height={600}
/>

Metadata

Export metadata to set the page's title, description, and social-sharing tags for SEO. Export a static object for constant metadata, or generateMetadata to compute it from route params — for example, a blog post's title and cover image.

// app/layout.tsx — static metadata
import type { Metadata } from "next";

export const metadata: Metadata = {
title: {
default: "My App",
template: "%s | My App",
},
description: "Description for SEO",
};

// Dynamic metadata
export async function generateMetadata({
params,
}: {
params: Promise<{ id: string }>;
}): Promise<Metadata> {
const { id } = await params;
const post = await getPost(id);

return {
title: post.title,
description: post.excerpt,
openGraph: {
images: [post.coverImage],
},
};
}

Environment variables

Next.js loads .env* files at build time and exposes every variable as a string. Prefix a variable with NEXT_PUBLIC_ to inline it into the client bundle; anything else is server-only and never shipped to the browser.

# .env.local (git-ignored, loaded in all environments)
DATABASE_URL="postgresql://localhost:5432/mydb"

# .env.production (only in production)
NEXT_PUBLIC_API_URL="https://api.example.com"

# Public (accessible in browser — prefix with NEXT_PUBLIC_)
NEXT_PUBLIC_GA_ID="G-XXXXXXXXXX"

# Server-only
API_SECRET="secret-value"
// Access in code
const dbUrl = process.env.DATABASE_URL; // server only
const gaId = process.env.NEXT_PUBLIC_GA_ID; // browser + server

Dockerfile

This two-stage Dockerfile builds with a full Node image, then copies only the compiled .next output into a slim production image running as a non-root user. Only NEXT_PUBLIC_ variables can be inlined at build time — pass them via ARG/ENV, and keep secrets in the runtime environment.

# ---- Build Stage ----
FROM node:22-alpine AS builder
WORKDIR /app

COPY package*.json ./
RUN npm ci

COPY . .

# Build-time env vars must be prefixed with NEXT_PUBLIC_ to be inlined
ARG NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL

RUN npm run build

# ---- Production Stage ----
FROM node:22-alpine
WORKDIR /app

RUN addgroup -S app && adduser -S app -G app

COPY --from=builder /app/package*.json ./
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public
COPY --from=builder /app/next.config.ts ./next.config.ts

USER app
EXPOSE 3000

ENV NODE_ENV=production
CMD ["npm", "start"]

Production tips

TipDetail
Prerender staticallyUse generateStaticParams for known routes (blog posts, product pages)
ISR over SSRrevalidate is cheaper than no-store — cache when you can
StreamingUse loading.tsx and <Suspense> for progressive rendering
Edge middlewareSimple redirects / rewrites in middleware.ts run at edge, not Node
Bundle analysisANALYZE=true npm run build with @next/bundle-analyzer
Database connectionsUse a singleton pattern to avoid connection leaks in serverless

See also