Skip to content
masterclassBuddyTech Buzz5 fresh blogs · 5:00 AM IST

Technology handbook

Next.js 16+ — Routing, Rendering & Caching

Routing, rendering, caching and the Next.js 16 toolchain — from the file system to production deployment.

Topics
37
Sections
10

Learning module

Next.js 16 learning module

Next.js is a React framework for building full-stack web applications: you use React Components to build the interface, and Next.js adds routing, rendering, data fetching, caching and optimizations on top, with its tooling configured for you.

  • Cache Components: nothing is cached unless the code says "use cache".
  • Runtime data such as cookies, headers and params must be read inside Suspense.
  • Every Server Function is a public endpoint: authenticate, authorize, validate.
  • proxy.ts replaces middleware.ts; Turbopack is the default bundler.

Choosing between fast static pages and fresh dynamic data: Building a React app from scratch means assembling routing, server rendering, bundling, code splitting, image and font optimization and an API layer yourself, then deciding page by page whether it is static (fast but stale) or dynamic (fresh but slow). Earlier Next.js versions cached implicitly, which surprised teams with stale data. Next.js 16 provides the full stack with zero configuration and, through Cache Components, lets one route mix a cached static shell with streamed personal or live data, with caching only where the code says "use cache".

Routing

Layouts and pages

Turn folders into URL segments and compose persistent layouts around pages.

  • A folder becomes a URL segment only when it holds a page file; a folder without one groups files without adding a path.
  • A layout keeps its state across navigations inside its subtree, which is why a sidebar's scroll position survives a page change.
  • Layouts nest from the root down, so the root layout renders on every route and is where <html> and <body> belong.

Example: app/dashboard/layout.tsx

import Link from "next/link";

export default function DashboardLayout({ children }: LayoutProps<"/dashboard">) {
  return (
    <div className="shell">
      <nav>
        <Link href="/dashboard">Overview</Link>
        <Link href="/dashboard/orders">Orders</Link>
      </nav>
      <main>{children}</main>
    </div>
  );
}

A shell that does not reload: Header, sidebar and their state stay mounted as the reader moves between pages, so only the page area re-renders on navigation.

Dynamic routes

Use awaited route params and catch-all segments for data-driven URLs.

  • params is a promise and must be awaited, which is the Next.js 16 change that breaks copied Next.js 14 code.
  • A catch-all [...slug] matches one or more segments; the optional [[...slug]] form also matches the bare parent path.
  • generateStaticParams decides which of those URLs are prerendered at build time instead of on first request.

Example: app/products/[id]/page.tsx

import { notFound } from "next/navigation";
import { getProduct } from "@/lib/products";

export default async function ProductPage({ params }: PageProps<"/products/[id]">) {
  const { id } = await params;
  const product = await getProduct(id);
  if (!product) notFound();
  return <h1>{product.name} — ₹{product.price}</h1>;
}

One file for ten thousand product pages: Every product shares one route whose id comes from the URL, rather than a file per product or a client-side lookup after load.

Route groups and parallel routes

Organize routes without changing URLs and render multiple route slots together.

  • A folder in parentheses organizes files without contributing a URL segment, which is how two sections can have different root layouts.
  • A parallel slot renders alongside the page and has its own loading and error states.
  • A slot with no match for the current URL falls back to its default file, and forgetting that file is what makes a slot vanish on refresh.

Example: app/(app)/dashboard/layout.tsx

export default function Layout({ children, revenue, team }: LayoutProps<"/dashboard">) {
  return (
    <div className="grid">
      {children}
      <section>{revenue}</section>
      <section>{team}</section>
    </div>
  );
}

A dashboard with independent panels: Feed, notifications and stats each load, fail and refresh on their own, instead of one slow panel holding up the whole view.

Nested layouts

Share navigation and state across a route subtree with layouts that persist between pages.

  • Each layout wraps everything below it, so shared chrome is declared once at the level where it applies.
  • A layout cannot read the current page's searchParams — that belongs to the page, which is the usual reason state is lifted into the URL.
  • Because layouts persist, an Effect in one runs on mount and not on every navigation beneath it.

Example: app/settings/layout.tsx

import Link from "next/link";

export default function SettingsLayout({ children }: LayoutProps<"/settings">) {
  return (
    <div className="settings">
      <aside>
        <Link href="/settings/profile">Profile</Link>
        <Link href="/settings/billing">Billing</Link>
      </aside>
      <section>{children}</section>
    </div>
  );
}

A settings area with its own navigation: Every settings page shares a tab bar that does not re-render or lose focus as the reader moves between tabs.

Intercepting routes

Show a route in the current layout, such as a modal, while retaining the underlying URL context.

  • An intercepting route renders another route's content in the current layout while the URL becomes that route's.
  • A hard refresh on that URL renders the real page, so the content must work both as a modal and as a full page.
  • The (..) markers count route segments, not folder depth, which is the part people get wrong first.

Example: app/@modal/(.)photos/[id]/page.tsx

import { Modal } from "@/components/modal";
import { getPhoto } from "@/lib/photos";

export default async function PhotoModal({ params }: PageProps<"/photos/[id]">) {
  const photo = await getPhoto((await params).id);
  return <Modal><img src={photo.url} alt={photo.alt} /></Modal>;
}

A photo that opens as a modal but shares as a page: Clicking a thumbnail opens it over the grid; sending that link to a friend opens the full page, with no duplicated component.

Rendering

Loading and error UI

Stream route-level loading states and recover from rendering or data errors.

  • A loading file wraps its segment in a Suspense boundary, so the fallback appears without writing one by hand.
  • An error file must be a Client Component — it receives the error and a reset function to retry the segment.
  • The root error file does not catch errors in the root layout; that needs global-error, which renders its own <html>.

Example: app/reports/loading.tsx

export default function Loading() { return <div className="skeleton" aria-busy="true">Building report…</div>; }

A slow section that does not blank the page: The reader sees the shell and a skeleton for the report area, and a failed query offers a retry rather than an empty route.

Server and Client Components

Choose the server by default and add client boundaries only for browser interactivity.

  • Components are server-rendered by default; 'use client' marks the boundary where the browser bundle begins.
  • The directive applies to the module and everything it imports, so one stray 'use client' high in the tree can pull the subtree into the bundle.
  • A Server Component can render a Client Component and pass it serializable props, but not the other way around — except as children.

Example: app/invoices/page.tsx

import { format } from "date-fns";
import { getInvoices } from "@/lib/billing";
import { SortToggle } from "./sort-toggle";

export default async function Invoices({ searchParams }: PageProps<"/invoices">) {
  const { sort } = await searchParams;
  const invoices = await getInvoices(sort === "old" ? "asc" : "desc");
  const inr = new Intl.NumberFormat("en-IN", { style: "currency", currency: "INR" });
  return (
    <>
      <SortToggle />
      <ul>{invoices.map(i => <li key={i.id}>{format(i.date, "d MMM yyyy")} — {inr.format(i.amount)}</li>)}</ul>
    </>
  );
}

Keeping a heavy formatter off the phone: Markdown or date-formatting libraries run on the server and ship as HTML, which is usually the largest single saving on a content route.

Streaming with Suspense

Send the shell early and stream slower data into boundaries as it becomes ready.

  • The shell is sent as soon as it is ready and slower boundaries arrive after it, on the same response.
  • Each boundary is independent, so a slow query delays only its own region.
  • An await above every boundary blocks the whole stream — where the await sits decides what the reader sees first.

Example: app/product/[id]/page.tsx

import { Suspense } from "react";
import { getProduct } from "@/lib/products";
import { Reviews } from "./reviews";

export default async function Product({ params }: PageProps<"/product/[id]">) {
  const { id } = await params;
  const product = await getProduct(id);
  return (
    <>
      <h1>{product.name}</h1>
      <Suspense fallback={<p>Loading reviews…</p>}><Reviews productId={id} /></Suspense>
    </>
  );
}

Showing navigation before the data arrives: A reader can start reading and clicking while a three-second analytics query finishes, instead of watching a blank tab.

Not-found handling

Render route-specific not-found UI and stop processing when a resource does not exist.

  • Calling notFound() throws, so nothing after it in the function runs — it is a control-flow exit, not a return value.
  • The nearest not-found file renders, which is how a section can have its own missing-resource page.
  • It produces a real 404 status, which is what search engines and monitoring need to see.

Example: app/invoices/[id]/page.tsx

import { notFound } from "next/navigation";

export default async function Invoice({ params }: PageProps<"/invoices/[id]">) {
  const invoice = await db.invoice.findUnique({ where: { id: (await params).id } });
  if (!invoice || invoice.deletedAt) notFound();
  return <h1>Invoice {invoice.number}</h1>;
}

A deleted record that must not look broken: An old link to a removed article shows the section's own not-found page and a correct status, rather than an empty template.

Metadata API

Define static and generated metadata, Open Graph images and canonical information per route.

  • Static metadata is for values known at build; generateMetadata is for values that depend on the request or the record.
  • Metadata from nested segments merges with its parents, so a page overrides only what it names.
  • An opengraph-image file generates the social image at build or request time, with no design tool in the loop.

Example: app/blog/[slug]/page.tsx

import type { Metadata } from "next";
import { getPost } from "@/lib/posts";

export async function generateMetadata({ params }: PageProps<"/blog/[slug]">): Promise<Metadata> {
  const post = await getPost((await params).slug);
  return { title: post.title, description: post.excerpt, openGraph: { type: "article", publishedTime: post.date } };
}

export default async function Post({ params }: PageProps<"/blog/[slug]">) {
  const post = await getPost((await params).slug);
  return <h1>{post.title}</h1>;
}

Link previews that show the actual article: A shared URL renders the post's own title, description and image instead of the site-wide default on every link.

Data & caching

Cache Components

Cache stable work with use cache, cacheLife and cacheTag while streaming live parts.

  • use cache marks work whose result may be reused, and the surrounding route can still stream live parts around it.
  • Everything a cached function closes over becomes part of its cache key, which is why passing a whole request object defeats it.
  • It is opt-in behind its flag: a route without it is rendered per request, as before.

Example: next.config.ts

import type { NextConfig } from "next";
export default { cacheComponents: true } satisfies NextConfig;

A price list read a thousand times a minute: The catalogue is computed once and reused while the cart beside it stays per-request, so traffic does not reach the database a thousand times.

Revalidation and invalidation

Refresh cached data deliberately with updateTag, revalidateTag and refresh.

  • revalidateTag marks content stale so the next request refreshes it; the current reader may still see the old copy.
  • updateTag refreshes immediately, which is what an author needs after saving their own change.
  • Invalidating from inside a server function keeps the write and the refresh in one place rather than in a webhook later.

Example: lib/profile.ts

import { cacheTag } from "next/cache";
export async function getProfile(userId: string) {
  "use cache";
  cacheTag(`profile-${userId}`);
  return db.profile.findUnique({ where: { userId } });
}

Seeing your own edit right away: An author saving a post should not have to refresh twice; other readers can pick the change up on their next visit.

Fetch caching

Understand request memoization, cached data and the boundaries introduced by Cache Components.

  • Identical fetches in one render are memoized, so two components asking for the same record cause one request.
  • Memoization lasts for one render pass only — it is not a cache between requests.
  • Reading cookies or headers makes a route dynamic, which is the usual reason a page that 'should be static' is not.

Example: lib/user.ts

import { cache } from "react";
import { cookies } from "next/headers";

export const getCurrentUser = cache(async () => {
  const id = (await cookies()).get("uid")?.value;
  return id ? db.user.findUnique({ where: { id } }) : null;
});

Two components that need the same user: A header and a sidebar both read the signed-in user without either passing it down or the server fetching it twice.

cacheLife and cacheTag

Set freshness profiles and invalidate related cached content by tag.

  • cacheLife sets how long a cached result stays fresh and how long it may be served stale while revalidating.
  • cacheTag names what the result depends on, so one write can invalidate every page built from it.
  • Tags are strings you choose — a scheme like product:42 is what makes targeted invalidation possible later.

Example: lib/articles.ts

import { cacheLife, cacheTag } from "next/cache";

export async function getArticle(slug: string) {
  "use cache";
  cacheLife("days");
  cacheTag("articles", `article-${slug}`);
  return cms.article(slug);
}

Publishing an edit without a full rebuild: Editing one product expires exactly the pages that show it, instead of waiting for a timer or redeploying the site.

updateTag and revalidateTag

Choose immediate invalidation or stale-while-revalidate behavior after a mutation.

  • updateTag refreshes the cached entry before the response is sent, so the author sees their own write on the next screen.
  • revalidateTag marks it stale and refreshes in the background, so the current reader may still be served the old copy once.
  • Both act on tags, not URLs, so what they affect is decided when the data is cached rather than when it is invalidated.

Example: app/admin/actions.ts

"use server";
import { updateTag } from "next/cache";
export async function editPrice(sku: string, price: number) {
  await db.product.update({ where: { sku }, data: { price } });
  updateTag("prices");
}

Choosing who waits for the refresh: An editor saving a post must not see stale text, while a thousand readers of the public page can take the refresh a moment later without blocking on it.

Mutations

Server Functions

Mutate data from forms and events, validating and authorizing every public action.

  • A server function is a public endpoint: its arguments arrive over the network, so authorization belongs inside it, not in the component that calls it.
  • It can be called from a form action or an event handler, and returns serializable data only.
  • Marking a whole module 'use server' exports every function in it as an endpoint — which is how one is exposed by accident.

Example: app/editor/actions.ts

"use server";
import { auth } from "@/lib/auth";
import { db } from "@/lib/db";

export async function saveDraft(postId: string, body: string) {
  const session = await auth();
  const post = await db.post.findUnique({ where: { id: postId } });
  if (!session || post?.authorId !== session.userId) throw new Error("Not allowed");
  await db.post.update({ where: { id: postId }, data: { body, savedAt: new Date() } });
  return { savedAt: new Date().toISOString() };
}

Saving a form without an API route: The write, its validation and the cache invalidation live in one function instead of a route file, a fetch wrapper and types kept in sync.

Forms and progressive enhancement

Submit mutations from server-rendered forms that remain useful before client JavaScript loads.

  • A form whose action is a server function submits before the JavaScript bundle loads, and upgrades silently once it does.
  • The action receives FormData, so field names in the markup are the contract.
  • Keeping validation in the action rather than in the client means the rule holds even when the request does not come from your form.

Example: app/signup/page.tsx

import { signup } from "./actions";

export default function SignupPage() {
  return (
    <form action={signup}>
      <input name="email" type="email" required />
      <button>Join the waitlist</button>
    </form>
  );
}

A sign-up that works on a bad connection: On a slow phone the form still posts and responds; the client bundle improves the experience rather than being required for it.

Cookies and sessions

Read and set cookies in the correct server context while protecting session state.

  • cookies() is awaited in Next.js 16 and can only be read in a server context — a Client Component cannot call it.
  • Setting a cookie is only allowed in a server function or a route handler, not while rendering a page.
  • Reading cookies opts the route out of static rendering, which is a deliberate trade, not a bug.

Example: lib/session.ts

import "server-only";
import { cookies } from "next/headers";
import { SignJWT, jwtVerify } from "jose";

const key = new TextEncoder().encode(process.env.SESSION_SECRET);

export async function createSession(userId: string) {
  const token = await new SignJWT({ userId }).setProtectedHeader({ alg: "HS256" }).setExpirationTime("7d").sign(key);
  (await cookies()).set("session", token, { httpOnly: true, secure: true, sameSite: "lax", path: "/", maxAge: 60 * 60 * 24 * 7 });
}

export async function getSession() {
  const token = (await cookies()).get("session")?.value;
  if (!token) return null;
  try { return (await jwtVerify(token, key)).payload as { userId: string }; } catch { return null; }
}

Keeping someone signed in: A session cookie is read on every server render to decide what the page shows, and set only where a response can legally carry it.

CSRF and action security

Treat Server Functions as public endpoints and validate origin, identity and input.

  • Every server function is reachable by anyone who can find its id, so identity must be re-checked inside it.
  • Next.js checks the request origin for actions, but that does not authorize the caller — that is still yours to do.
  • Validating the shape of the arguments with a schema stops a crafted call reaching the database.

Example: app/projects/actions.ts

"use server";
import { verifySession } from "@/lib/dal";
import { updateTag } from "next/cache";

export async function deleteProject(projectId: string) {
  const session = await verifySession();
  const project = await db.project.findUnique({ where: { id: projectId } });
  if (project?.ownerId !== session.userId) throw new Error("Only the owner can delete this project");
  await db.project.delete({ where: { id: projectId } });
  updateTag("projects");
}

A delete button only an owner may press: Hiding the button in the UI is not protection; the ownership check has to run in the function that performs the delete.

Request handling

Proxy

Use the Next.js 16 proxy convention for request-time redirects and routing decisions.

  • proxy is the Next.js 16 convention that replaces the middleware file, with the same request-time position.
  • It runs before routing, so it suits redirects, rewrites and auth gates — not data fetching.
  • It runs on every matched request, which makes anything slow in it a cost paid site-wide.

Example: proxy.ts

import { NextResponse, type NextRequest } from "next/server";

export function proxy(req: NextRequest) {
  if (req.cookies.has("session")) return NextResponse.next();
  return NextResponse.redirect(new URL(`/sign-in?next=${req.nextUrl.pathname}`, req.url));
}

export const config = { matcher: ["/account/:path*"] };

Sending signed-out readers to sign-in: One rule redirects every protected path before a page renders, instead of a check repeated in twenty route files.

Headers, cookies and redirects

Read request metadata and perform safe redirects in the correct server context.

  • headers() and cookies() are awaited in Next.js 16 and are server-only.
  • redirect() throws, so code after it does not run, and it must not be called inside a try block that swallows the throw.
  • permanentRedirect is a 308 that browsers and crawlers cache — the wrong choice is hard to take back.

Example: app/page.tsx

import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";

export default async function Root() {
  const saved = (await cookies()).get("locale")?.value;
  const accepted = (await headers()).get("accept-language")?.split(",")[0]?.slice(0, 2);
  const locale = saved ?? (["en", "hi"].includes(accepted ?? "") ? accepted : "en");
  redirect(`/${locale}`);
}

Routing by locale or role on the server: The decision is made before render from the request's own headers, so no flash of the wrong page reaches the browser.

Proxy matchers

Limit proxy execution to the paths that need redirects, rewrites or request policy.

  • A matcher limits which paths run the proxy, and omitting one means it runs for assets and images too.
  • Matchers are evaluated before the function, so an excluded path costs nothing at all.
  • Static files, _next internals and the favicon are the usual exclusions people forget until they see the latency.

Example: proxy.ts

import { NextResponse, type NextRequest } from "next/server";

export function proxy(req: NextRequest) {
  if (!req.cookies.has("session")) return NextResponse.redirect(new URL("/sign-in", req.url));
}

export const config = {
  matcher: ["/((?!_next/static|_next/image|favicon.ico|.*\\.(?:png|jpg|svg|webp)$|sign-in).*)"],
};

Not paying auth latency on every image: A dashboard's images should not pass through the auth check that its pages need, which a matcher expresses in one line.

Middleware migration to Proxy

Move the Next.js 15 Middleware convention to the Next.js 16 proxy file convention.

  • The file is renamed to proxy and the exported function to proxy; the config and matcher shape carry over.
  • The Node.js runtime is available, so code that needed the edge runtime's restricted API set no longer does.
  • Leaving the old file in place means the rule silently stops running, which is the failure this migration causes.

Example: terminal

$ npx @next/codemod@canary middleware-to-proxy .

Bringing a Next.js 15 app forward: An existing auth or locale middleware keeps working after the rename, without rewriting the logic inside it.

Toolchain

Turbopack

Build and develop with the default incremental bundler and its filesystem cache.

  • It is the default bundler for dev and build in Next.js 16, with a filesystem cache that survives a restart.
  • Incremental compilation means the second change to a file is far cheaper than the first — cold and warm timings are not comparable.
  • Some webpack loaders and plugins have no equivalent, which is the usual reason a project cannot move yet.

Example: next.config.ts

import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  turbopack: {
    rules: { "*.svg": { loaders: ["@svgr/webpack"], as: "*.js" } },
    resolveAlias: { "lodash": "lodash-es" },
  },
};
export default nextConfig;

A dev server that keeps up with a large app: Edit-to-refresh is what a team pays fifty times a day, and it is where build tooling either saves or wastes an afternoon.

DevTools MCP

Inspect routes, runtime errors and build information through the Next.js developer tooling.

  • It exposes routes, build information and runtime errors to tooling rather than only to the terminal.
  • It reports which segments are static and which dynamic, which answers 'why is this route not cached' directly.
  • It is a development aid and is not part of the production server.

Example: .mcp.json

{
  "mcpServers": {
    "next-devtools": { "command": "npx", "args": ["-y", "next-devtools-mcp@latest"] }
  }
}

Finding why a page went dynamic: A route that used to prerender now renders per request, and the cause is one call somewhere in its tree.

Environment variables

Keep server secrets private and expose only explicitly prefixed public configuration.

  • Only names prefixed NEXT_PUBLIC_ reach the browser; everything else stays on the server.
  • Public values are inlined at build time, so changing one needs a rebuild, not a restart.
  • A secret read in a Client Component is a secret published — the prefix rule is the whole protection.

Example: .env.local

STRIPE_SECRET_KEY=sk_live_...        # server only
NEXT_PUBLIC_STRIPE_KEY=pk_live_...   # safe in the browser

One API key that must never ship: The browser needs the project URL while the service key stays server-side, and the prefix is what keeps them apart.

Performance

Images, fonts and metadata

Ship optimized assets and complete page metadata without hand-tuning every route.

  • Images are resized and served in modern formats per device, which is usually the largest transfer saving on a page.
  • Fonts are self-hosted at build time, removing a third-party request and the layout shift that comes with it.
  • Width and height (or fill) reserve space before load, which is what keeps Cumulative Layout Shift near zero.

Example: app/layout.tsx

import { Inter } from "next/font/google";
import type { Metadata } from "next";

const inter = Inter({ subsets: ["latin"], display: "swap" });
export const metadata: Metadata = { title: { template: "%s | Shop", default: "Shop" } };

export default function RootLayout({ children }: LayoutProps<"/">) {
  return <html lang="en" className={inter.className}><body>{children}</body></html>;
}

Passing Core Web Vitals on a phone: A hero image and a web font are the two things most likely to fail the field measurement that search ranking uses.

Static and dynamic rendering

Choose when a route can be prerendered and when request-time data requires dynamic rendering.

  • A route is prerendered until something request-specific is read — cookies, headers, or searchParams.
  • Dynamic is not slower by nature; it is uncached, which is a different and sometimes correct choice.
  • The build output lists each route's mode, which is the fastest way to catch a page that changed by accident.

Example: app/(marketing)/pricing/page.tsx

export default function Pricing() {
  return <h1>Plans from ₹349/month</h1>;
}

A marketing page and a dashboard in one app: The landing page is served from cache worldwide while the account page renders per request, from the same codebase.

Bundle analysis

Find client bundle costs and move browser-free work back across the server boundary.

  • The client bundle only contains what a Client Component imports — tracing an import chain is how a stray dependency is found.
  • A single 'use client' near the root can pull a subtree's imports into the browser.
  • Moving formatting, parsing or date work back to the server usually beats trying to shrink the library doing it.

Example: terminal

$ npx next experimental-analyze

A route that ships 900KB for no reason: One icon set or date library imported in a client file accounts for most of it, and the analyzer names the file.

next/image

Resize, optimize and serve responsive images with safe remote source configuration.

  • Remote sources must be allow-listed in config, which stops the optimizer being used as an open proxy.
  • priority preloads the image the reader sees first; using it on several images removes the benefit.
  • fill needs a positioned parent, and forgetting that is why an image sometimes collapses to nothing.

Example: components/gallery.tsx

import Image from "next/image";

export function Gallery({ photos }: { photos: { src: string; alt: string }[] }) {
  return (
    <div className="gallery">
      {photos.map((p, i) => (
        <Image key={p.src} src={p.src} alt={p.alt} width={800} height={600} sizes="(max-width: 640px) 100vw, 33vw" preload={i === 0} />
      ))}
    </div>
  );
}

A product gallery on a slow connection: Each thumbnail arrives at the size that device needs rather than a 3MB original scaled down in the browser.

next/font

Load fonts with build-time optimization, stable layout and privacy-friendly local assets.

  • Fonts are downloaded at build time and served from your own domain, so no request reaches a font CDN.
  • A fallback is generated with matching metrics, which is what stops text jumping when the real font lands.
  • Loading a font inside a component instead of at module scope re-triggers the work on every render.

Example: app/fonts.ts

import { Inter } from "next/font/google";
import localFont from "next/font/local";

export const inter = Inter({ subsets: ["latin"], variable: "--font-inter" });
export const brand = localFont({ src: "./brand.woff2", variable: "--font-brand" });

Brand type without the layout jump: Headings render in the right font immediately and do not reflow the page a moment after it appears.

API routes

Route Handlers

Expose typed request handlers for webhooks, JSON APIs and other HTTP endpoints.

  • A handler exports a function per HTTP method and returns a standard Response.
  • It runs on the server with the app's environment, which is why webhooks belong here rather than in a separate service.
  • A handler that reads the request body is dynamic by definition and is never prerendered.

Example: app/api/payments/webhook/route.ts

import { createHmac, timingSafeEqual } from "node:crypto";
import { after } from "next/server";

export async function POST(req: Request) {
  const raw = await req.text();
  const sig = Buffer.from(req.headers.get("x-razorpay-signature") ?? "");
  const expected = Buffer.from(createHmac("sha256", process.env.RAZORPAY_WEBHOOK_SECRET!).update(raw).digest("hex"));
  if (sig.length !== expected.length || !timingSafeEqual(sig, expected)) return new Response("bad signature", { status: 400 });

  const event = JSON.parse(raw);
  after(() => fulfilOrder(event)); // slow work after the response
  return Response.json({ received: true });
}

Receiving a payment webhook: The provider needs a plain URL to POST to, with signature verification and database access in the same deployment as the app.

Authentication and authorization

Protect pages, Server Functions and Route Handlers with checks at the data boundary.

  • Checks belong at the data boundary: a layout check protects what it renders, not what a server function can be called to do.
  • Session reading in a layout does not run for server functions, which is the gap most often shipped.
  • Authorization is per resource, so 'is signed in' is rarely the same question as 'may see this record'.

Example: lib/dal.ts

import "server-only";
import { cache } from "react";
import { redirect } from "next/navigation";
import { getSession } from "./session";

export const verifySession = cache(async () => {
  const session = await getSession();
  if (!session) redirect("/sign-in");
  return session;
});

export async function getAccount(accountId: string) {
  const session = await verifySession();
  const account = await db.account.findUnique({ where: { id: accountId } });
  if (account?.ownerId !== session.userId) throw new Error("Forbidden");
  return account;
}

An account page nobody else may open: Guarding the page is not enough when the same data is reachable through an action or a handler with a guessed id.

Testing App Router routes

Test handlers, server actions and route behavior at the boundary users and clients call.

  • A route handler is a function from Request to Response, so it can be tested without starting a server.
  • Server functions are ordinary async functions — their authorization checks are the part worth asserting.
  • End-to-end tests are what cover streaming and navigation, which unit tests of a component cannot observe.

Example: app/api/payments/webhook/route.test.ts

import { createHmac } from "node:crypto";
import { POST } from "./route";

process.env.RAZORPAY_WEBHOOK_SECRET = "test-secret";

function signed(body: string, secret = "test-secret") {
  const sig = createHmac("sha256", secret).update(body).digest("hex");
  return new Request("http://test/api", { method: "POST", body, headers: { "x-razorpay-signature": sig } });
}

test("rejects a forged signature", async () => {
  const res = await POST(signed("{}", "wrong-secret"));
  expect(res.status).toBe(400);
});

test("accepts a correctly signed event", async () => {
  const res = await POST(signed(JSON.stringify({ event: "payment.captured" })));
  expect(res.status).toBe(200);
});

Proving the webhook still verifies signatures: A refactor that drops a check should fail a test rather than be discovered by an unauthorized POST in production.

Ask follow-up questions on any topic in your dashboard.

Start learning