Next.js Mastery
Quick Reference
| Concern | Key API / Pattern | |---|---| | App Router layouts | app/layout.tsx, template.tsx | | Loading / Error UI | loading.tsx, error.tsx, global-error.tsx | | Parallel routes | @folder slot convention | | Intercepting routes | (.) / (..) / (...) folder convention | | Server Components | default in app/; "use client" directive | | SSR / SSG / ISR | dynamic, revalidate, generateStaticParams | | Streaming | + async Server Components | | Server Actions | "use server" functions | | Route Handlers | app/api/route.ts | | Middleware | middleware.ts at project root | | Caching | fetch options, unstable_cache, revalidateTag | | Security | CSP headers, CSRF tokens, rate limiting, Zod validation |
1. App Router Architecture
1.1 Special File Conventions
| File | Purpose | |---|---| | layout.tsx | Shared UI wrapping children; persists across navigations | | template.tsx | Like layout but re-mounts on every navigation | | loading.tsx | Suspense fallback while segment loads | | error.tsx | Error boundary (must be client component) | | global-error.tsx | Catches root layout errors | | not-found.tsx | Shown when notFound() is thrown | | default.tsx | Fallback for unmatched parallel routes |
// app/layout.tsx — root layout (required)
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
{/ persistent nav /}
{children}
);
}
// app/dashboard/error.tsx "use client"; export default function DashboardError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) { return (
Something went wrong! Try again ); }
1.2 App Router Request Flow
flowchart TD
A["Browser Request"] --> B["middleware.ts"]
B -->|rewrite/redirect/continue| C["App Router Match"]
C --> D["Root Layout"]
D --> E["Nested Layouts"]
E --> F["Page / Template + loading.tsx"]
F --> G{"Data Fetching"}
G -->|Server Component| H["Await async data"]
G -->|Client Component| I["Render + Hydrate"]
H --> J["Stream HTML via Suspense"]
I --> J
J --> K["Complete Response"]
K --> L["Client Hydration"]
style A fill:#e1f5fe style B fill:#fff3e0 style L fill:#e8f5e9
1.3 Parallel Routes
Named slots (@folder) render multiple pages in one layout simultaneously:
// app/dashboard/layout.tsx
export default function DashboardLayout({
children, analytics, team,
}: { children: React.ReactNode; analytics: React.ReactNode; team: React.ReactNode }) {
return (
{children}
{analytics}
{team}
);
}
// app/dashboard/@analytics/page.tsx → renders in analytics slot
// app/dashboard/@team/page.tsx → renders in team slot
1.4 Intercepting Routes
Modal context in-app, full page on direct URL:
app/dashboard/photos/[id]/page.tsx ← full page (direct URL)
app/dashboard/(.)photos/[id]/page.tsx ← modal (intercepted, same-level)
2. Rendering Strategies
2.1 Rendering Decision Tree
flowchart TD
A["Component"] --> B{"Browser APIs / interactivity?"}
B -->|Yes| C["Client Component 'use client'"]
B -->|No| D{"Data fetch?"}
D -->|No| E["Static Server Component (SSG at build)"]
D -->|Yes| F{"Real-time?"}
F -->|No| G{"Revalidation interval?"}
G -->|None| H["SSG: generateStaticParams"]
G -->|Timed| I["ISR: revalidate=N"]
F -->|Yes| J{"Defer parts?"}
J -->|Yes| K["Streaming SSR + Suspense"]
J -->|No| L["Full SSR dynamic='force-dynamic'"]
style C fill:#ffcdd2 style E fill:#c8e6c9 style I fill:#fff9c4 style K fill:#bbdefb
2.2 SSR, SSG, ISR, Streaming
// ISR: revalidate every hour
export const revalidate = 3600;
export default async function ProductsPage() {
const products = await db.product.findMany({ take: 20 });
return {products.map(p => {p.name} )} ;
}
// SSR: always server-render export const dynamic = "force-dynamic"; export default async function DashboardPage() { const stats = await fetch("https://api.example.com/stats", { cache: "no-store" }).then(r => r.json()); return ; }
// SSG: static at build time export function generateStaticParams() { return db.post.findMany({ select: { slug: true } }); } export default function BlogPost({ params }: { params: { slug: string } }) { return ; }
// Streaming with Suspense — progressive page load import { Suspense } from "react"; export default function DashboardPage() { return (
Dashboard }> }> ); } async function RevenueChart() { const data = await fetchRevenue(); return ; }
3. Data Fetching Patterns
3.1 Server Actions
// app/actions/create-post.ts
"use server";
import { z } from "zod";
const schema = z.object({ title: z.string().min(1).max(200), content: z.string().min(1) });
export async function createPost(formData: FormData) { const result = schema.safeParse({ title: formData.get("title") as string, content: formData.get("content") as string, }); if (!result.success) return { error: result.error.flatten().fieldErrors };
await db.post.create({ data: result.data }); revalidatePath("/blog"); redirect("/blog"); }
// app/blog/new/page.tsx — client component using Server Action
"use client";
import { useFormState, useFormStatus } from "react-dom";
import { createPost } from "@/app/actions/create-post";
function SubmitButton() { const { pending } = useFormStatus(); return {pending ? "Creating..." : "Create Post"} ; }
export default function NewPostPage() { const [state, formAction] = useFormState(createPost, null); return (
); }
3.2 Route Handlers
// app/api/webhook/route.ts
import { NextRequest, NextResponse } from "next/server";
import { verifySignature } from "@/lib/webhook";
export async function POST(request: NextRequest) { const body = await request.text(); const sig = request.headers.get("x-webhook-signature"); if (!verifySignature(body, sig)) { return NextResponse.json({ error: "Invalid signature" }, { status: 401 }); } await processWebhook(JSON.parse(body)); return NextResponse.json({ received: true }); }
3.3 Middleware
// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { verifyToken } from "@/lib/jwt";
export function middleware(request: NextRequest) { if (request.nextUrl.pathname.startsWith("/login")) return NextResponse.next();
const token = request.cookies.get("session")?.value; if (!token) return NextResponse.redirect(new URL("/login", request.url));
try { const payload = verifyToken(token); const response = NextResponse.next(); response.headers.set("x-user-id", payload.sub); return response; } catch { return NextResponse.redirect(new URL("/login", request.url)); } }
export const config = { matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"], };
3.4 Caching & Revalidation
// No cache (always fresh)
await fetch("https://api.example.com/live", { cache: "no-store" });
// Time-based ISR await fetch("https://api.example.com/data", { next: { revalidate: 600 } });
// Tag-based revalidation await fetch("https://api.example.com/products", { next: { tags: ["product-catalog"] } });
// On-demand revalidation import { revalidatePath, revalidateTag } from "next/cache"; revalidatePath("/products"); revalidateTag("product-catalog");
// Caching non-fetch sources (DB, ORM) import { unstable_cache } from "next/cache"; const getCachedProducts = unstable_cache( () => db.product.findMany(), ["products-list"], { revalidate: 3600, tags: ["product-catalog"] } );
4. Next.js Security
// next.config.ts
import type { NextConfig } from "next";
const cspHeader = default-src 'self'; script-src 'self' 'unsafe-eval' 'unsafe-inline' https://vercel.live; style-src 'self' 'unsafe-inline'; img-src 'self' blob: data: https:; font-src 'self' https://fonts.gstatic.com; connect-src 'self' https://api.example.com; frame-ancestors 'none'; form-action 'self';.trim();
const nextConfig: NextConfig = { async headers() { return [{ source: "/(.*)", headers: [ { key: "Content-Security-Policy", value: cspHeader.replace(/\n/g, " ") }, { key: "X-Frame-Options", value: "DENY" }, { key: "X-Content-Type-Options", value: "nosniff" }, { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, { key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" }, { key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains; preload" }, ], }]; }, }; export default nextConfig;
4.2 CSRF & Env Var Security
// lib/csrf.ts
import { headers } from "next/headers";
export function validateCSRF(): boolean {
const origin = headers().get("origin");
const host = headers().get("host");
if (!origin || !host) return false;
return new URL(origin).host === host;
}
// Server-only modules — prevents client bundle leakage // app/lib/db.ts import "server-only"; import { PrismaClient } from "@prisma/client"; export const db = new PrismaClient({ datasourceUrl: process.env.DATABASE_URL });
// app/api/users/route.ts — Zod validation
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
const schema = z.object({ email: z.string().email(), name: z.string().min(1).max(100), role: z.enum(["user", "admin"]).default("user"), });
export async function POST(request: NextRequest) { try { const data = schema.parse(await request.json()); const user = await db.user.create({ data }); return NextResponse.json(user, { status: 201 }); } catch (error) { if (error instanceof z.ZodError) { return NextResponse.json({ error: error.flatten() }, { status: 422 }); } return NextResponse.json({ error: "Internal error" }, { status: 500 }); } }
// lib/rate-limit.ts
import { headers } from "next/headers";
const store = new Map();
export function rateLimit({ windowMs = 60_000, maxRequests = 10 } = {}) { const ip = headers().get("x-forwarded-for") ?? "unknown"; const now = Date.now(); const entry = store.get(ip); if (!entry || now > entry.resetAt) { store.set(ip, { count: 1, resetAt: now + windowMs }); return { success: true, remaining: maxRequests - 1 }; } if (entry.count >= maxRequests) return { success: false, remaining: 0 }; entry.count++; return { success: true, remaining: maxRequests - entry.count }; }
// Usage in Route Handler: export async function POST(request: NextRequest) { const { success } = rateLimit({ maxRequests: 5 }); if (!success) return NextResponse.json({ error: "Too many requests" }, { status: 429 }); }
4.4 Security Checklist
[ ] CSP, HSTS, X-Frame-Options, X-Content-Type-Options headers configured
[ ] Server secrets never in NEXT_PUBLIC_ env vars; use server-only package
[ ] All Route Handler inputs validated with Zod
[ ] Rate limiting on auth/password-reset/public API routes
[ ] CSRF origin verification on mutating Route Handlers
[ ] User HTML sanitized with DOMPurify before rendering
[ ] authorizedRedirects in next.config.ts preventing open redirects
[ ] Cookies set with httpOnly, secure, sameSite flags
[ ] Regular npm audit and next lint with strict rules
5. Testing + Deployment
5.1 Jest + React Testing Library
// jest.config.ts
import nextJest from "next/jest";
const createJestConfig = nextJest({ dir: "./" });
export default createJestConfig({
testEnvironment: "jsdom",
setupFilesAfterSetup: ["/jest.setup.ts"],
moduleNameMapper: { "^@/(.*)$": "/src/$1" },
});
// __tests__/components/ProductCard.test.tsx
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import ProductCard from "@/components/ProductCard";
describe("ProductCard", () => { it("renders name and price", () => { render( ); expect(screen.getByText("Widget")).toBeInTheDocument(); }); it("calls onAddToCart on click", async () => { const onAddToCart = jest.fn(); render( ); await userEvent.setup().click(screen.getByRole("button", { name: /add to cart/i })); expect(onAddToCart).toHaveBeenCalledWith("Widget"); }); });
5.2 Testing Server Actions
jest.mock("@/lib/db", () => ({ db: { post: { create: jest.fn().mockResolvedValue({ id: "1" }) } } }));
jest.mock("next/cache", () => ({ revalidatePath: jest.fn() }));
it("creates post with valid data", async () => { const fd = new FormData(); fd.set("title", "Test"); fd.set("content", "Hello"); const result = await createPost(fd); expect(result).not.toHaveProperty("error"); });
it("returns error for empty title", async () => { const fd = new FormData(); fd.set("title", ""); fd.set("content", "content"); expect((await createPost(fd)).error).toBeDefined(); });
5.3 Playwright E2E
// playwright.config.ts
import { defineConfig } from "@playwright/test";
export default defineConfig({
webServer: { command: "npm run build && npm start", port: 3000, reuseExistingServer: true },
testDir: "./e2e",
use: { baseURL: "http://localhost:3000" },
});
// e2e/blog.spec.ts
import { test, expect } from "@playwright/test";
test("can navigate to blog post", async ({ page }) => {
await page.goto("/blog");
await page.click("text=First Post");
await expect(page).toHaveURL(/\/blog\/first-post/);
});
test("can create a post", async ({ page }) => {
await page.goto("/blog/new");
await page.fill('[name="title"]', "E2E Post");
await page.fill('[name="content"]', "By Playwright");
await page.click('button:text("Create Post")');
await expect(page.locator("text=E2E Post")).toBeVisible();
});
5.4 Deployment Pipeline
flowchart LR
A["Git Push"] --> B["CI Pipeline"]
B --> C["Lint + Type Check"]
C --> D["Unit Tests (Jest)"]
D --> E["E2E Tests (Playwright)"]
E --> F["next build"]
F --> G{"Target?"}
G -->|Vercel| H["vercel --prod"]
G -->|Docker| I["docker build"]
G -->|Self-host| J["standalone + PM2"]
H --> K["CDN + Edge"]
I --> L["Container Registry"]
J --> M["Bare Metal / VM"]
K --> N["Live"]
L --> N
M --> N
style A fill:#e1f5fe style N fill:#e8f5e9
5.5 Docker Deployment
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
FROM node:20-alpine AS builder WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . RUN npm run build
FROM node:20-alpine AS runner WORKDIR /app ENV NODE_ENV=production RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs COPY --from=builder /app/public ./public COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static USER nextjs EXPOSE 3000 ENV PORT=3000 HOSTNAME="0.0.0.0" CMD ["node", "server.js"]
// next.config.ts — required for Docker standalone
import type { NextConfig } from "next";
export default { output: "standalone" } satisfies NextConfig;
5.6 Edge Functions & CI/CD
// app/api/geo/route.ts — Edge Runtime
export const runtime = "edge";
import { NextRequest, NextResponse } from "next/server";
export async function GET(request: NextRequest) {
return NextResponse.json({
country: request.geo?.country ?? "unknown",
city: request.geo?.city ?? "unknown",
});
}
# .github/workflows/ci.yml
name: CI/CD
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: "20", cache: "npm" }
- run: npm ci
- run: npm run lint
- run: npx tsc --noEmit
- run: npm test -- --coverage
- run: npx playwright install --with-deps
- run: npx playwright test
deploy:
needs: test
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: amondnet/vercel-action@v25
with:
vercel-token: ${{ secrets.VERCEL_TOKEN }}
vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
vercel-args: "--prod"
6. Edge Cases & Production Patterns
// Dynamic params — Next 15+: params is a Promise
import { notFound } from "next/navigation";
export const dynamicParams = false; // 404 for non-generated slugs
export default async function ProductPage({ params }: { params: { id: string } }) {
const { id } = await Promise.resolve(params);
const product = await fetchProduct(id);
if (!product) notFound();
return ;
}
// Optimistic updates with useOptimistic "use client"; import { useOptimistic } from "react"; export function LikeButton({ postId, initialLiked }: { postId: string; initialLiked: boolean }) { const [liked, addOpt] = useOptimistic(initialLiked); return { addOpt(!liked); toggleLike(postId); }}>{liked ? "❤️" : "🤍"} ; }
// Image optimization — always use next/image import Image from "next/image"; export function Hero() { return ; }
7. Pattern Selection Guide
| Pattern | When to Use | |---|---| | Server Components | Data fetching, no interactivity | | Client Components | Event handlers, hooks, browser APIs | | Layouts | Shared UI persisting across routes | | Templates | Shared UI resetting on navigation | | Parallel Routes | Multi-panel dashboards | | Intercepting Routes | Modals degrading to full pages | | Suspense Streaming | Progressive load, avoid full-page blocking | | Server Actions | Form mutations with revalidation | | Route Handlers | REST APIs, webhooks, non-React consumers | | Middleware | Auth gates, redirects, geo-routing | | ISR | Semi-dynamic with time-based revalidation | | Edge Runtime | Low-latency, globally distributed logic | | unstable_cache | Caching non-fetch data (DB, ORM) | | server-only | Prevent client leakage of secrets | | Zod validation | All API/Route Handler input validation |