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​