Advanced Patterns

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Next.js 16.3.8 (the latest release on 2026-10-03, when the facts and examples below were checked; the official documentation reports @doc-version 16.3.8), React 19.3.0 and Node.js 20.9.0 or later. It covers the App Router, because every pattern here has an App Router form, and points to the Pages Router equivalent where one exists (MDX, Draft Mode and the Edge runtime exist in both; the rest are App Router only or are router independent). Where caching changes the outcome the page labels the two models: the previous model (the default, cacheComponents off) and Cache Components (cacheComponents: true, opt-in). Every example was built with next build and run with next start on port 3904, then exercised with curl and, for the browser features, with a real Chrome driven by Playwright, on Next.js 16.3.8 and Node.js 24.16 (macOS), in a scratch app that grows the Bookshelf catalogue used throughout this section. The scratch app was built and run twice, once per caching model, and every observed status code, header and log line quoted below is real output.

The basics of a Next.js application (routing, rendering, data, caching, security) are covered by the other pages of this section. This page collects seven patterns that most applications eventually need and that the official documentation treats as separate guides: content in MDX, previewing unpublished content, serving several customers from one deployment, installable and offline-tolerant apps, animated navigation, running code at the edge, and chat interfaces for language models. The last and longest part is a complete, corrected e-commerce flow with Stripe Checkout, because the two consulted books both teach one and both contain security errors that must not be reproduced.

The Patterns at a Glance

Table 1. Advanced patterns, the Next.js feature behind each, and where they run
Pattern Built on Router Status in 16.3

MDX

@next/mdx, mdx-components.tsx, pageExtensions

App Router and Pages Router

Stable. The Rust compiler (mdxRs) is experimental

Draft Mode

draftMode() from next/headers, a Route Handler, the __prerender_bypass cookie

App Router (Pages Router has res.setDraftMode)

Stable

Multi-tenant

Proxy rewrite from the host name to a route segment

App Router (Pages Router can rewrite as well)

Pattern, not an API. The official guide points to a starter kit

PWA and offline

app/manifest.ts, a service worker in public/, experimental.useOffline

App Router

Manifest and service worker are browser features. useOffline is experimental and new in 16.3

View transitions

React <ViewTransition>, <Link transitionTypes>

App Router

transitionTypes since 16.2. No configuration needed

Edge runtime

export const runtime = 'edge'

Both routers

Deprecated. Proxy runs on Node.js in 16

AI chat UIs

A streaming Route Handler and a Client Component

Both routers

Plain web APIs. Libraries are optional

Commerce

Server Action, Route Handler, Stripe Checkout

Both routers

Stripe API version 2026-09-30.endive with stripe 23.0.0

MDX: Markdown With Components

Markdown turns plain text into HTML. MDX is a superset of it that lets you write JSX inside the Markdown, so a post can embed a chart or a call-to-action component. @next/mdx compiles .mdx (and optionally .md) files into React components. In the App Router those components are Server Components by default, so MDX adds no JavaScript to the browser unless the components it imports are Client Components.

Four pieces make it work: the packages (@next/mdx, @mdx-js/loader, @mdx-js/react, @types/mdx), the createMDX wrapper in next.config, pageExtensions so that page.mdx counts as a page, and a root file named mdx-components.tsx, which is required by the App Router integration. The scratch app installed @next/mdx 16.3.8, @mdx-js/loader 3.1.1, @mdx-js/react 3.1.1 and @types/mdx 2.0.14.

next.config.ts: enabling MDX pages
import type { NextConfig } from "next";
import createMDX from "@next/mdx";

const nextConfig: NextConfig = {
  pageExtensions: ["js", "jsx", "md", "mdx", "ts", "tsx"],
  cacheComponents: process.env.CACHE_COMPONENTS === "1",
  experimental: {
    useOffline: true,
  },
  async headers() {
    return [
      {
        source: "/sw.js",
        headers: [
          { key: "Content-Type", value: "application/javascript; charset=utf-8" },
          { key: "Cache-Control", value: "no-cache, no-store, must-revalidate" },
          { key: "Content-Security-Policy", value: "default-src 'self'; script-src 'self'" },
        ],
      },
    ];
  },
};

const withMDX = createMDX({});

export default withMDX(nextConfig);

Two details are easy to miss. First, by default @next/mdx compiles only the .mdx extension. To handle .md files as well, pass extension: /\.(md|mdx)$/ to createMDX (documented for webpack; the scratch app runs Turbopack and used .mdx only). Second, the pageExtensions list is what turns app/docs/page.mdx into a route; without it the file is ignored.

The file that decides how Markdown elements render is mdx-components.tsx, at the project root next to app (or inside src). It exports useMDXComponents, which returns a map from element names to components:

mdx-components.tsx: global overrides for every MDX file
import type { MDXComponents } from "mdx/types";

const components = {
  h1: ({ children }) => <h1 className="text-3xl font-bold">{children}</h1>,
  a: ({ href, children }) => (
    <a href={href} rel="noopener noreferrer">
      {children}
    </a>
  ),
} satisfies MDXComponents;

export function useMDXComponents(): MDXComponents {
  return components;
}

There are three ways to put MDX on a route. File-based routing uses an MDX file as the page itself; imports render a file from anywhere; and dynamic imports choose the file from the URL, which is the usual shape for a blog:

app/docs/page.mdx: an MDX file is a page
export const metadata = { title: "Docs (MDX)" }

# Welcome to the Bookshelf docs

This page is **MDX**: Markdown plus JSX, routed like any other `page` file.

- Rendered on the server
- Styled through `mdx-components.tsx`

[Next.js](https://nextjs.org/)
app/blog/[slug]/page.tsx: one route, many MDX files, all prerendered
export default async function Page({ params }: PageProps<"/blog/[slug]">) {
  const { slug } = await params;
  const { default: Post } = await import(`@/content/${slug}.mdx`);
  return <Post />;
}

export function generateStaticParams() {
  return [{ slug: "welcome" }, { slug: "about" }];
}

export const dynamicParams = false;

The imported file keeps its exports, which is the supported way to attach front matter (@next/mdx does not parse YAML front matter by itself; the documentation lists remark-frontmatter, remark-mdx-frontmatter and gray-matter for that):

content/welcome.mdx: metadata as an export
export const meta = { title: "Welcome post", author: "Ada" }

# Welcome

Posts live in `content/` and are imported by slug.

Observed after next build and next start (previous model):

$ curl -s localhost:3904/docs | grep -o '<article.*' | head -c 120
<article data-layout="docs"><h1 class="text-3xl font-bold">Welcome to the Bookshelf docs</h1>
$ curl -s localhost:3904/blog/welcome | grep -o '<h1[^>]*>[^<]*</h1>'
<h1 class="text-3xl font-bold">Welcome</h1>
$ curl -s -o /dev/null -w '%{http_code}\n' localhost:3904/blog/nope
404

The build route table lists /blog/welcome and /blog/about as ● (prerendered by generateStaticParams) and /docs as ○ (static). The h1 class comes from mdx-components.tsx, which proves that the global map is applied.

Plugins (remark-gfm, rehype-slug and so on) go in createMDX({ options: { remarkPlugins: […​] } }). With Turbopack, the default bundler, plugins must be given by name as strings and may only take serialisable options, because JavaScript functions cannot be passed to Rust; the documentation says plugins without serialisable options cannot be used with Turbopack yet. The experimental Rust compiler is switched on with experimental.mdxRs; the guide says it is not recommended for production.

Security: an MDX file is a program. It can import modules and evaluate JavaScript expressions at build or render time, so compile only content written by people you would also trust with a pull request. Content from untrusted authors belongs in plain Markdown rendered through a sanitising pipeline, never in MDX.

Two things change under Cache Components. The route segment option dynamicParams is not allowed any more: the build of the example above fails with Route segment config "dynamicParams" is not compatible with nextConfig.cacheComponents. Please remove it. The generateStaticParams list is then the set of prerendered pages and other slugs render on demand, so validate them yourself (for example, call notFound() when the import fails). In the Pages Router the same packages and pageExtensions apply, and MDX files live under pages/ (see the Pages Router MDX guide).

Draft Mode With a Headless CMS

A statically generated page shows the content that existed at build or revalidation time. Editors in a headless CMS want to see an unpublished edit now, on the real site, without waiting. Draft Mode does that: it sets a cookie in the editor’s browser, and for requests that carry the cookie Next.js bypasses its caches and renders on demand. Everyone else still receives the cached, published page.

The contract with the CMS is a preview URL such as /api/draft/enable?secret=<token>&slug=<entry>. The Route Handler must (1) check the shared secret, (2) check that the entry exists, (3) call draftMode().enable() and (4) redirect to a path derived from the CMS entry, not copied from the query string, which would be an open redirect. The secret comes from an environment variable and is compared in constant time; it is never written in the source.

app/api/draft/enable/route.ts: enable Draft Mode for a CMS preview
import { timingSafeEqual } from "node:crypto";
import { draftMode } from "next/headers";
import { redirect } from "next/navigation";
import { getPost } from "@/lib/cms";

function safeEqual(a: string, b: string) {
  const left = Buffer.from(a);
  const right = Buffer.from(b);
  return left.length === right.length && timingSafeEqual(left, right);
}

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);
  const secret = searchParams.get("secret");
  const slug = searchParams.get("slug");
  const expected = process.env.DRAFT_SECRET;

  if (!expected || !secret || !slug || !safeEqual(secret, expected)) {
    return new Response("Invalid token", { status: 401 });
  }
  // Ask the CMS for the entry (draft version included) and redirect to the path
  // we derive from it, never to a path taken from the query string.
  const post = await getPost(slug, true);
  if (!post) {
    return new Response("Invalid slug", { status: 404 });
  }
  const draft = await draftMode();
  draft.enable();
  redirect(`/posts/${post.slug}`);
}
app/api/draft/disable/route.ts: leaving preview with POST
import { draftMode } from "next/headers";

export async function POST() {
  const draft = await draftMode();
  draft.disable();
  return new Response(null, { status: 204 });
}

The page does not need to know whether it is a preview in order to get fresh data, because Draft Mode already bypasses the caches. It reads isEnabled only to choose the draft or the published endpoint of the CMS and to show a banner. The stand-in CMS client below is server-only so that it, and any real token it would hold, cannot be bundled into the browser:

lib/cms.ts and app/posts/[slug]/page.tsx: the preview-aware page
import "server-only";

type Post = { slug: string; title: string; body: string };

const published: Record<string, Post> = {
  hello: { slug: "hello", title: "Hello", body: "Published text." },
};
const drafts: Record<string, Post> = {
  hello: { slug: "hello", title: "Hello (draft)", body: "Unpublished edit." },
  upcoming: { slug: "upcoming", title: "Upcoming", body: "Not published yet." },
};

export async function getPost(slug: string, preview: boolean): Promise<Post | null> {
  // A real CMS client would call its preview API here when `preview` is true.
  return (preview ? (drafts[slug] ?? published[slug]) : published[slug]) ?? null;
}
import { draftMode } from "next/headers";
import { notFound } from "next/navigation";
import { getPost } from "@/lib/cms";

export function generateStaticParams() {
  return [{ slug: "hello" }];
}

export default async function Page({ params }: PageProps<"/posts/[slug]">) {
  const { slug } = await params;
  const { isEnabled } = await draftMode();
  const post = await getPost(slug, isEnabled);
  if (!post) notFound();
  return (
    <main>
      {isEnabled && <p role="status">Preview mode is on.</p>}
      <h1>{post.title}</h1>
      <p>{post.body}</p>
    </main>
  );
}

The throw-away secret was generated in the shell (openssl rand -hex 12) and exported as DRAFT_SECRET; the transcript below uses $SECRET for it. Results with next start in the previous model (the Cache Components build gave the same results for the cookie cases: Hello (draft) with the cookie, 404 for the unpublished post without it, 200 with it):

$ curl -s -o /dev/null -w '%{http_code}\n' "localhost:3904/api/draft/enable?secret=wrong&slug=hello"
401
$ curl -s -o /dev/null -w '%{http_code}\n' "localhost:3904/api/draft/enable?secret=$SECRET&slug=nope"
404
$ curl -s -D- -o /dev/null "localhost:3904/api/draft/enable?secret=$SECRET&slug=upcoming"
HTTP/1.1 307 Temporary Redirect
location: /posts/upcoming
set-cookie: __prerender_bypass=<value>; Path=/; Secure; HttpOnly; SameSite=none
$ curl -s localhost:3904/posts/hello | grep -o '<h1>[^<]*</h1>'        # published, cached
<h1>Hello</h1>
$ curl -s -H "Cookie: $COOKIE" localhost:3904/posts/hello | grep -o '<h1>[^<]*</h1>'
<h1>Hello (draft)</h1>
$ curl -s -o /dev/null -w '%{http_code}\n' localhost:3904/posts/upcoming              # not published
404
$ curl -s -o /dev/null -w '%{http_code}\n' -H "Cookie: $COOKIE" localhost:3904/posts/upcoming
200

Two response headers show the mechanism. The public page was a cache hit with x-nextjs-cache: HIT and Cache-Control: s-maxage=31536000; the same URL with the bypass cookie came back with Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate, so no shared cache can keep a draft. The cookie value differs per next build, which is why it cannot be guessed. Disabling is a POST that returns 204 with an expired cookie.

Points that matter in production:

  • Enabling Draft Mode from a GET is acceptable only because a CMS opens the URL in a new tab; the guide says operations that affect later requests should use POST otherwise, and that exit links should be forms, because a <Link> is prefetched and would clear the cookie before the click.

  • To try it locally over plain HTTP, the API reference says the browser must allow third-party cookies and local storage access; the cookie is Secure; SameSite=none, as the transcript above shows.

  • Under Cache Components, draftMode().isEnabled can be read inside a 'use cache' scope (the component then re-executes on every draft request and nothing is saved), but enable() and disable() cannot be called there; keep them in Route Handlers or Server Actions, as above.

  • In the Pages Router the equivalent is an API Route in pages/api that calls res.setDraftMode({ enable: true }), and getStaticProps receives context.draftMode. The older Preview Mode API still has its own Pages Router guide; Draft Mode is the current one.

  • A CMS usually also needs the opposite direction: when an editor publishes, the CMS calls a webhook that revalidates the affected tags. That is covered in Incremental Static Regeneration and Caching.

Multi-Tenant Apps

A multi-tenant application serves many customers (tenants) from one deployment, usually on a subdomain each (acme.example.com, globex.example.com) or on custom domains. The official guide is a short pointer to the Platforms Starter Kit template, which it presents as the recommended architecture; the page contains no API, because Next.js has no tenant feature. The pieces that make the pattern work are ordinary ones: the Proxy rewrites the host name to a route segment, the route segment carries the tenant to the pages, and the data layer scopes every query by that tenant.

proxy.ts: map acme.example.com to /tenants/acme (a rewrite, the address bar does not change)
import { NextResponse, type NextRequest } from "next/server";

const ROOT_DOMAIN = process.env.ROOT_DOMAIN ?? "localhost";

export function proxy(request: NextRequest) {
  const host = request.headers.get("host")?.split(":")[0] ?? "";
  const subdomain = host.endsWith(`.${ROOT_DOMAIN}`)
    ? host.slice(0, -(ROOT_DOMAIN.length + 1))
    : null;

  // Only accept well-formed tenant names before they reach a route path.
  if (subdomain && /^[a-z0-9-]{1,63}$/.test(subdomain)) {
    const url = request.nextUrl.clone();
    url.pathname = `/tenants/${subdomain}${url.pathname}`;
    return NextResponse.rewrite(url);
  }
  return NextResponse.next();
}

export const config = {
  matcher: ["/((?!api|_next/static|_next/image|.*\\..*).*)"],
};
app/tenants/[tenant]/page.tsx: the tenant home page
import { notFound } from "next/navigation";

const tenants: Record<string, { name: string; color: string }> = {
  acme: { name: "Acme Books", color: "tomato" },
  globex: { name: "Globex Reads", color: "steelblue" },
};

export function generateStaticParams() {
  return Object.keys(tenants).map((tenant) => ({ tenant }));
}

export const dynamicParams = false;

export default async function TenantHome({ params }: PageProps<"/tenants/[tenant]">) {
  const { tenant } = await params;
  const config = tenants[tenant];
  if (!config) notFound();
  return (
    <main style={{ borderTop: `4px solid ${config.color}` }}>
      <h1>{config.name}</h1>
      <p>Tenant: {tenant}</p>
    </main>
  );
}

Observed (previous model), with ROOT_DOMAIN left at its default localhost and the Host header set by curl (browsers resolve *.localhost to the loopback address, so the same works in a browser on acme.localhost:3904):

$ curl -s -H 'Host: acme.localhost:3904' localhost:3904/ | grep -o '<h1>[^<]*</h1>'
<h1>Acme Books</h1>
$ curl -s -H 'Host: globex.localhost:3904' localhost:3904/ | grep -o '<h1>[^<]*</h1>'
<h1>Globex Reads</h1>
$ curl -s -o /dev/null -w '%{http_code}\n' -H 'Host: nobody.localhost:3904' localhost:3904/
404
$ curl -s -o /dev/null -w '%{http_code}\n' localhost:3904/tenants/acme
200

The last line is a pitfall, not a feature: the rewritten route is also reachable directly on the main domain. If tenant pages must be reachable only through the tenant host, make the Proxy answer 404 for paths that start with /tenants/ when the host has no tenant subdomain.

Design points that the starter kit and the running example share:

  • Validate the tenant before it becomes a path. The Proxy accepts only [a-z0-9-] up to 63 characters; anything else passes through untouched. A tenant that is not in the data source ends in notFound().

  • Static where possible. The tenant list feeds generateStaticParams, so /tenants/acme is prerendered (●). With dynamicParams = false an unknown tenant is a 404 without rendering. Under Cache Components dynamicParams is not allowed (remove it), and the unknown tenant still ended in 404 through notFound(); the example returned 404 in both builds.

  • The tenant is part of the cache key. Because the rewrite puts the tenant in the path, every ISR or Cache Components entry is per tenant. If you pass the tenant in a header instead, remember that caches key on the URL.

  • Never trust a tenant sent by the browser. Derive it from the host in the Proxy; do not accept an x-tenant header from the outside. Scope every database query by the tenant id (see the Data Access Layer in Data Security and CSP) and set cookies for the tenant host only.

  • Custom domains need a lookup from host name to tenant. The Proxy runs on Node.js in 16, so it can call an internal service, but it runs before every matched request: cache the lookup and keep it fast.

  • Localised tenants combine this with Internationalization: do the locale redirect and the tenant rewrite in one Proxy function, because a project has one proxy.ts.

PWAs and Offline Support

A Progressive Web App is a website that the browser can install and that keeps working, at least partly, when the network does not. Three browser features do the work, and Next.js contributes to only the first and, since 16.3, to a fourth that is experimental:

Table 2. The building blocks of a PWA and who provides each
Block What it does In Next.js

Web app manifest

Name, icons, start URL and display mode, so the browser can offer installation

app/manifest.ts (or manifest.json), served by Next.js as /manifest.webmanifest

Service worker

A script that the browser runs in the background: push messages, request interception, caching

A plain file in public/, registered by a Client Component

Push notifications

Server-initiated messages through the browser vendor’s push service (the Web Push protocol and VAPID keys)

Your own subscription storage and a sender such as the web-push package

Connectivity-aware navigation

Keeps a failed navigation, prefetch or Server Action pending and retries it when the network returns

experimental.useOffline and the useOffline hook from next/offline, new in 16.3 and experimental

The manifest. Because it is a metadata file convention, it is typed and served for you:

app/manifest.ts
import type { MetadataRoute } from "next";

export default function manifest(): MetadataRoute.Manifest {
  return {
    name: "Bookshelf",
    short_name: "Bookshelf",
    description: "A reading list that works on a flaky connection",
    start_url: "/",
    display: "standalone",
    background_color: "#ffffff",
    theme_color: "#0b5fff",
    icons: [{ src: "/icon-192.png", sizes: "192x192", type: "image/png" }],
  };
}

The service worker. The scratch worker handles push messages and notification clicks only. It sits in public/ so that it is served at /sw.js with scope /. The headers() function in the next.config shown in the MDX section gives that URL Cache-Control: no-cache, no-store, must-revalidate and a strict Content Security Policy, as in the official guide, so that browsers always fetch the newest worker:

public/sw.js
// Minimal service worker: handles push messages and notification clicks.
self.addEventListener("push", (event) => {
  if (!event.data) return;
  const data = event.data.json();
  event.waitUntil(
    self.registration.showNotification(data.title, {
      body: data.body,
      icon: "/icon-192.png",
    }),
  );
});

self.addEventListener("notificationclick", (event) => {
  event.notification.close();
  event.waitUntil(clients.openWindow("/"));
});
app/pwa/register.tsx: registering the worker from a Client Component
"use client";

import { useEffect, useState } from "react";

export function RegisterServiceWorker() {
  const [state, setState] = useState<"unsupported" | "registering" | "ready">("registering");

  useEffect(() => {
    if (!("serviceWorker" in navigator)) {
      setState("unsupported");
      return;
    }
    navigator.serviceWorker
      .register("/sw.js", { scope: "/", updateViaCache: "none" })
      .then(() => setState("ready"))
      .catch(() => setState("unsupported"));
  }, []);

  return <p>Service worker: {state}</p>;
}

Results of next build and next start, plus a headless Chrome visit to /pwa:

$ curl -s -D- localhost:3904/manifest.webmanifest | grep -i -E '^HTTP|content-type'
HTTP/1.1 200 OK
content-type: application/manifest+json
$ curl -s -D- -o /dev/null localhost:3904/sw.js | grep -i -E 'content-type|cache-control|content-security'
Content-Type: application/javascript; charset=utf-8
Cache-Control: no-cache, no-store, must-revalidate
Content-Security-Policy: default-src 'self'; script-src 'self'
# Playwright, real Chrome:  <p>Service worker: ready</p>  and  getRegistrations().length === 1

Push notifications need a few more parts that cannot be shown end to end without a push service and a browser permission prompt, so they were not run here. The official guide builds them as follows: generate a VAPID key pair with the web-push command line, expose the public key to the browser as NEXT_PUBLIC_VAPID_PUBLIC_KEY (a public key is meant to be public) and keep the private key in VAPID_PRIVATE_KEY on the server only; the Client Component calls registration.pushManager.subscribe with userVisibleOnly: true and posts the subscription to a Server Action; the server stores it in a database (the guide’s in-memory variable is only a demo) and sends messages with webpush.sendNotification; the worker above displays them. Test locally over HTTPS with next dev --experimental-https. Installation to the home screen on iOS needs iOS 16.4 or later, and push works there only for apps installed that way. Validate the origin and payload of anything you accept as a push subscription, as you would any other input.

useOffline. A normal Next.js app that loses its connection during a soft navigation, a prefetch or a Server Action call throws a fetch error, and the UI breaks or must build its own retry. With the experimental option enabled, those requests no longer throw: Next.js keeps them pending, polls the current page with a HEAD request (stepped delays of 500 ms, 1 s, 2 s and then 3 s, each aborted after 200 ms) and runs them again when the connection returns. The hook reports the state so the UI can say what is happening.

The configuration is one key, shown in the next.config.ts of the MDX section (experimental: { useOffline: true }), and a banner in the root layout is the smallest use:

app/offline-banner.tsx
"use client";

import { useOffline } from "next/offline";

export function OfflineBanner() {
  const isOffline = useOffline();
  if (!isOffline) return null;
  return (
    <div role="status">Offline. Pending requests will retry once you are back online.</div>
  );
}

The build printed ✓ useOffline under "Experiments". A Chrome page driven by Playwright showed no banner while online; after the browser context was switched offline the banner Offline. Pending requests will retry once you are back online. appeared within a second; and after the context came back online it disappeared again. This was repeated against the Cache Components build with the same result.

What the feature does not do is as important. It applies to soft navigations into prefetched routes and to Server Actions called from the current page. A full page reload while offline still fails, because the browser needs the network to receive the HTML; a page that must load offline needs a service worker that caches it (see the PWA guide). fetch calls you write in a Client Component, and data libraries such as React Query or SWR, keep their own retry policy. The hook returns false during server rendering and before hydration. The documentation labels both the option and the hook experimental and not recommended for production, and its version history only says v16.x.0; the version baseline of this section (the 16.3 release notes) places it in 16.3.

View Transitions

Navigation replaces a whole page at once, so nothing tells the reader that the thumbnail they clicked is the same picture as the hero on the next page. The browser’s View Transitions API animates between the old and the new state of the DOM, and React’s <ViewTransition> component drives it declaratively: you name the elements that persist, and React and the browser animate their size and position.

In the App Router no configuration is required: the guide states that view transitions work with no configuration because the App Router uses React canary releases, which already contain ViewTransition, so you do not install a canary yourself. (Older tutorials may mention an experimental Next.js flag; the 16.3 guide and next.config types list none, and the example below uses none.) The animations are triggered by Transitions, <Suspense> reveals and useDeferredValue; plain setState does not trigger them, and route navigations in Next.js are Transitions, so they work automatically.

Next.js 16.2 added a transitionTypes prop to <Link>. The types are passed to React’s addTransitionType inside the navigation transition, so a <ViewTransition> can choose a different animation for "forward" and "back". The mapping from a type to an animation is yours: the framework does not know which links are forward links.

app/gallery/page.tsx: the list page, a shared element and a forward link
import { ViewTransition } from "react";
import Link from "next/link";
import { photos } from "@/lib/photos";

export default function Gallery() {
  return (
    <ViewTransition
      enter={{ "nav-back": "nav-back", default: "none" }}
      exit={{ "nav-forward": "nav-forward", default: "none" }}
      default="none"
    >
      <main>
        <h1>Gallery</h1>
        <ul>
          {photos.map((photo) => (
            <li key={photo.id}>
              <Link href={`/gallery/${photo.id}`} transitionTypes={["nav-forward"]}>
                <ViewTransition name={`photo-${photo.id}`} share="morph" default="none">
                  <span className="thumb">{photo.title}</span>
                </ViewTransition>
              </Link>
            </li>
          ))}
        </ul>
      </main>
    </ViewTransition>
  );
}
app/gallery/[id]/page.tsx: the detail page, the matching name and a back link
import { ViewTransition } from "react";
import Link from "next/link";
import { notFound } from "next/navigation";
import { photos } from "@/lib/photos";

export function generateStaticParams() {
  return photos.map((p) => ({ id: p.id }));
}

export default async function Photo({ params }: PageProps<"/gallery/[id]">) {
  const { id } = await params;
  const photo = photos.find((p) => p.id === id);
  if (!photo) notFound();
  return (
    <ViewTransition
      enter={{ "nav-forward": "nav-forward", default: "none" }}
      exit={{ "nav-back": "nav-back", default: "none" }}
      default="none"
    >
      <main>
        <Link href="/gallery" transitionTypes={["nav-back"]}>
          Back to the gallery
        </Link>
        <ViewTransition name={`photo-${photo.id}`} share="morph" default="none">
          <h1 className="hero">{photo.title}</h1>
        </ViewTransition>
      </main>
    </ViewTransition>
  );
}

The two elements with the same name (here photo-1) are paired by React, which makes the thumbnail morph into the hero. share="morph" assigns the CSS class morph; default="none" stops every named element from cross-fading on unrelated transitions. The CSS then targets the generated pseudo-elements (::view-transition-group(.morph), ::view-transition-old(.nav-forward) and so on); the scratch app’s globals.css holds the rules from the guide’s "directional navigation" step. Put the page-level <ViewTransition> in each page.tsx, not in a layout: layouts persist across navigations, so enter and exit never fire there.

The build type-checked import { ViewTransition } from 'react' with react 19.3.0 and @types/react. An animation cannot be seen with curl, so a Playwright script in Chrome recorded the calls the framework makes to the browser:

startViewTransition supported: true
after clicking the thumbnail link:  [ '["nav-forward"]' ]
after clicking "Back to the gallery": [ '["nav-forward"]', '["nav-back"]' ]

Each navigation called document.startViewTransition with the types from the clicked link, in both caching models.

Things to know:

  • Browsers: the transition types and view-transition-class that React uses need Chromium 125 or later and recent Safari and Firefox. Where they are unsupported the app works and simply does not animate.

  • A morph plays only when the destination renders in the same commit as the navigation, which is the case for prefetched (cached) pages. If the destination suspends into a fallback first, no pair forms and the content uses its enter animation when it arrives.

  • Animations are decoration. Respect prefers-reduced-motion in your CSS, and do not hide state changes that the user needs to perceive behind an animation.

  • The Pages Router has no <ViewTransition> integration in its documentation; this is an App Router feature.

The Edge Runtime

Next.js has two server runtimes. The Node.js runtime is the default and has every Node API. The Edge runtime is a smaller, Web-standard environment that was designed to start fast and run close to the user; a route chose it with a route segment option:

app/api/edge-demo/route.ts and app/api/node-demo/route.ts: the two runtimes side by side
export const runtime = "edge";

export async function GET() {
  return Response.json({ runtime: process.env.NEXT_RUNTIME ?? "unknown" });
}
import { hostname } from "node:os";

export async function GET() {
  return Response.json({ runtime: process.env.NEXT_RUNTIME, hasFs: typeof hostname() === "string" });
}

Both worked:

$ curl -s localhost:3904/api/edge-demo
{"runtime":"edge"}
$ curl -s localhost:3904/api/node-demo
{"runtime":"nodejs","hasFs":true}

But the build of 16.3.8 printed these warnings, and the documentation now says the same: 'edge' is deprecated as a value of the runtime option, and the migration is to delete the line, because Node.js is the default and no replacement is needed.

⚠ The Edge Runtime is deprecated. You can use the "nodejs" runtime instead. Learn more: https://nextjs.org/docs/messages/edge-runtime-deprecated
⚠ Using edge runtime on a page currently disables static generation for that page

The practical consequences:

Table 3. What the Edge runtime means in Next.js 16.3
Topic Fact

Status

export const runtime = 'edge' is deprecated. Remove it from page, layout, route files and API Routes (message edge-runtime-deprecated)

Proxy

proxy.ts (the renamed Middleware, 16.0) runs on the Node.js runtime and the runtime option is not available there: setting it throws. middleware.ts is still accepted for Edge use cases but is deprecated and will be removed

Limits

No native Node.js APIs (for example the file system), no require (use ES modules), no eval or new Function, no WebAssembly.compile or instantiate; node_modules packages work only if they are ES modules without native APIs; no Incremental Static Regeneration

Static generation

A page that uses the Edge runtime is not statically generated

Streaming

Both runtimes can stream, depending on the deployment adapter

Cache Components

The runtime option is rejected at build time when cacheComponents is on (see the failure below)

With cacheComponents: true the same route fails the build before anything is generated:

Error: Route segment config "runtime" is not compatible with `nextConfig.cacheComponents`. Please remove it.
> 1 | export const runtime = "edge";

When is the Edge runtime still the right answer? Only if you deploy to a platform whose edge network offers it and your code is a small, stateless function that the limits above allow. Even then, the documented direction is Node.js, so treat an existing runtime = 'edge' as technical debt, and prefer a Node.js route in front of a nearby database or API over an edge function that must call it across an ocean. The Pages Router has its own Edge Runtime reference with the same list of supported and unsupported APIs.

AI Chat UIs

A chat interface is a form, a list of messages and a response that arrives in pieces. The browser needs the words as they are produced, because a model may take many seconds to finish. Next.js needs no special feature for this: a Route Handler returns a Response whose body is a ReadableStream, and a Client Component reads the stream and appends text. The AI pages of this site cover the models and architecture behind it; this section shows only the Next.js part, and a stand-in model (an async generator that yields words with a small delay) keeps the example runnable without any provider account:

lib/mock-model.ts and app/api/chat/route.ts: a validated, streaming endpoint
import "server-only";

// Stand-in for a model provider: yields tokens with a small delay.
export async function* streamReply(prompt: string): AsyncGenerator<string> {
  const words = `You asked: ${prompt}. Here is a streamed answer, one word at a time.`.split(" ");
  for (const word of words) {
    await new Promise((resolve) => setTimeout(resolve, 20));
    yield word + " ";
  }
}
import { z } from "zod";
import { streamReply } from "@/lib/mock-model";

const Body = z.object({
  messages: z
    .array(z.object({ role: z.enum(["user", "assistant"]), content: z.string().max(2000) }))
    .min(1)
    .max(20),
});

export async function POST(request: Request) {
  const parsed = Body.safeParse(await request.json().catch(() => null));
  if (!parsed.success) {
    return Response.json({ error: "Invalid request" }, { status: 400 });
  }
  const last = parsed.data.messages.at(-1)!;
  const encoder = new TextEncoder();

  const stream = new ReadableStream({
    async start(controller) {
      try {
        for await (const token of streamReply(last.content)) {
          if (request.signal.aborted) break;
          controller.enqueue(encoder.encode(token));
        }
        controller.close();
      } catch (error) {
        controller.error(error);
      }
    },
  });

  return new Response(stream, {
    headers: { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store" },
  });
}
app/chat/chat.tsx: reading the stream in a Client Component
"use client";

import { useState } from "react";

type Message = { role: "user" | "assistant"; content: string };

export function Chat() {
  const [messages, setMessages] = useState<Message[]>([]);
  const [input, setInput] = useState("");
  const [pending, setPending] = useState(false);

  async function send(event: React.FormEvent) {
    event.preventDefault();
    const next: Message[] = [...messages, { role: "user", content: input }];
    setMessages([...next, { role: "assistant", content: "" }]);
    setInput("");
    setPending(true);

    const response = await fetch("/api/chat", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ messages: next }),
    });
    if (!response.ok || !response.body) {
      setPending(false);
      return;
    }
    const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
    for (;;) {
      const { value, done } = await reader.read();
      if (done) break;
      setMessages((current) => {
        const copy = current.slice();
        const last = copy[copy.length - 1];
        copy[copy.length - 1] = { ...last, content: last.content + value };
        return copy;
      });
    }
    setPending(false);
  }

  return (
    <form onSubmit={send}>
      <ul aria-live="polite">
        {messages.map((m, i) => (
          <li key={i}>
            <strong>{m.role}:</strong> {m.content}
          </li>
        ))}
      </ul>
      <input value={input} onChange={(e) => setInput(e.target.value)} maxLength={2000} />
      <button disabled={pending || input.trim() === ""}>Send</button>
    </form>
  );
}

Run with next start:

$ curl -sN -X POST -H 'Content-Type: application/json' \
    -d '{"messages":[{"role":"user","content":"Recommend a novel"}]}' localhost:3904/api/chat -w '\n[%{http_code}]\n'
You asked: Recommend a novel. Here is a streamed answer, one word at a time.
[200]
$ curl -s -X POST -H 'Content-Type: application/json' -d '{"messages":[]}' localhost:3904/api/chat -w ' [%{http_code}]\n'
{"error":"Invalid request"} [400]
$ curl -s -X POST -d 'x' localhost:3904/api/chat -w ' [%{http_code}]\n'
{"error":"Invalid request"} [400]

In Chrome, typing Hello and pressing Send produced the user line and then the assistant line, filled in word by word. -N turns off `curl’s buffering so that the words are printed as they arrive.

Rules that apply to every chat endpoint, mock or real:

  • Treat the body as hostile. The route validates the shape with zod, caps the number and length of messages, and answers 400 otherwise. A real endpoint adds authentication and a rate limit, because every request costs money.

  • Keep provider keys on the server. The key lives in an unprefixed environment variable read by a server-only module; the browser talks only to your route. See Configuration, TypeScript and Environment Variables.

  • Never render model output as HTML. The Client Component renders text through React, which escapes it. If you add Markdown rendering, sanitise it. The security page for LLM output covers the risk in depth: Improper Output Handling.

  • Do not stream and cache the same response. The handler sets Cache-Control: no-store; a POST handler is never cached by Next.js in either model.

  • Stop work when the user leaves. The loop checks request.signal so that it stops pulling tokens from the model when the client disconnects; with a paid provider, pass the signal to the provider call too.

  • Check your hosting’s buffering. Some platforms and proxies buffer responses; deployment behaviour is a topic of the deployment page of this section, which is written separately.

Libraries can remove the boilerplate. The Vercel AI SDK provides streamText on the server and a useChat hook in @ai-sdk/react for the client, with message state, status and stop built in (see the AI SDK chatbot guide); it is not required, and the example above shows the plain mechanism it is built on. For the larger picture, the AI section of this site has AI Application Architecture, Structured Output and Tool Calling (a chat that shows tool results), Agent UX (interaction design for streaming, approvals and errors), Serving RAG as an API (the backend of a "chat with your documents" page), Cost, Latency and Throughput and Excessive Agency and Tool Security (before a chat can call tools on behalf of a user).

A Corrected Commerce Worked Example: Stripe Checkout

Both consulted books end in an online shop: a catalogue from a headless CMS, a cart kept in React state, product pages, and payment through Stripe Checkout, with a success page and a cancel page. Those concepts are all still valid, and this section rebuilds them with the current Next.js and Stripe APIs. It deliberately does not reproduce several errors that the books contain, which are called out where the corrected code differs:

  • the Checkout line items are built from the prices that the browser posted (a customer can change them), and the quantity is not validated;

  • a Stripe secret key is named with the NEXT_PUBLIC_ prefix, which publishes it in the JavaScript bundle, and the build warning is silenced instead of fixed;

  • the redirect to the success page is treated as proof of payment, with no webhook;

  • the browser loads Stripe.js and calls redirectToCheckout, an older client-side flow that Stripe has since removed from its current TypeScript types (@stripe/stripe-js 8.0.0, 2025-10-01).

The corrected flow keeps every decision that matters on the server:

sequenceDiagram participant B as Browser participant A as Server Action participant C as Server catalogue participant S as Stripe API participant W as Webhook Route Handler B->>A: POST product ids and quantities only A->>C: look up the price of each id A->>A: validate quantities, save a pending order A->>S: create Checkout Session from server prices S-->>A: session with url A-->>B: 303 redirect to session.url B->>S: pays on the Stripe-hosted page S->>W: POST signed event with raw body W->>W: verify signature on the raw text W->>S: retrieve the session W->>W: check amount, mark the order paid once S-->>B: redirect to the success page B->>A: GET success page, shows the order status

The same flow as a checklist, with the file that implements each step:

Table 4. The corrected checkout, step by step
Step What happens File

1

The form posts only product ids and quantities. A price field in the request is simply never read

app/shop/page.tsx

2

Quantities are validated (integer, 0 to 10), prices come from a server-only catalogue, and an order is saved as pending with a server-computed total

lib/catalog.ts, lib/orders.ts, app/shop/actions.ts

3

The Checkout Session is created on the server and the user is redirected to session.url with status 303

app/shop/actions.ts

4

Stripe sends a signed checkout.session.completed (or checkout.session.async_payment_succeeded) event to the webhook

app/api/stripe/webhook/route.ts

5

The handler verifies the signature on the raw body, retrieves the session, compares the paid amount with the stored total and marks the order paid exactly once

app/api/stripe/webhook/route.ts, lib/orders.ts

6

The success page only reports the status in our own database; it does not prove payment

app/shop/success/page.tsx

Secrets. Three environment variables are involved and none has the NEXT_PUBLIC_ prefix: STRIPE_SECRET_KEY (API access), STRIPE_WEBHOOK_SECRET (signature verification) and APP_URL (the origin for the redirect URLs). They are read only in server-only modules, kept in .env.local (which is not committed) or in the host’s secret store, and never appear in the source of this page or the scratch app. Stripe’s documentation says the same: do not put keys in code. The only Stripe value that may ever be public is the publishable key, and the hosted-page flow below does not need it, because no Stripe.js runs in the browser.

The catalogue is the one source of truth for prices, in minor units (cents) so that no floating-point arithmetic is involved. In a real shop it is a database or CMS query, but the rule is the same: the browser names a product, the server decides the price.

lib/catalog.ts: prices live on the server only
import "server-only";

export type Product = { id: string; name: string; unitAmount: number; currency: "eur" };

// The single source of truth for prices, in minor units (cents).
// In a real shop this is a database or a CMS query, never the client.
const products: Product[] = [
  { id: "dune", name: "Dune", unitAmount: 1299, currency: "eur" },
  { id: "solaris", name: "Solaris", unitAmount: 999, currency: "eur" },
];

export function listProducts(): Product[] {
  return products;
}

export function findProduct(id: string): Product | undefined {
  return products.find((p) => p.id === id);
}

Source: Stripe API: line_items with price_data (the amount is an integer in the smallest currency unit)

The Stripe client is created lazily. stripe 23.0.0 (the latest on 2026-10-03, pinned to API version 2026-09-30.endive) throws when the key is missing, and next build imports route modules without your secrets, so a client created at import time can fail the build. The scratch app was built with no Stripe variables at all.

lib/stripe.ts: a server-only, lazily created client
import "server-only";
import Stripe from "stripe";

let client: Stripe | null = null;

// Created lazily so that `next build` does not need the key to be present.
export function getStripe(): Stripe {
  if (!client) {
    const key = process.env.STRIPE_SECRET_KEY;
    if (!key) throw new Error("STRIPE_SECRET_KEY is not set");
    client = new Stripe(key);
  }
  return client;
}
lib/orders.ts: the order record that links our database to Stripe (a Map stands in for a database)
import "server-only";
import { randomUUID } from "node:crypto";

export type OrderLine = { productId: string; quantity: number; unitAmount: number };
export type Order = {
  id: string;
  lines: OrderLine[];
  total: number;
  currency: string;
  status: "pending" | "paid";
  sessionId?: string;
};

// Stand-in for a database. A module-level Map would be duplicated per bundle,
// so the scratch app keeps it on globalThis. Use a real database in production.
const store = ((globalThis as unknown as { __orders?: Map<string, Order> }).__orders ??= new Map());

export function createOrder(lines: OrderLine[], currency: string): Order {
  const total = lines.reduce((sum, l) => sum + l.unitAmount * l.quantity, 0);
  const order: Order = { id: randomUUID(), lines, total, currency, status: "pending" };
  store.set(order.id, order);
  return order;
}

export function getOrder(id: string): Order | undefined {
  return store.get(id);
}

export function attachSession(id: string, sessionId: string) {
  const order = store.get(id);
  if (order) order.sessionId = sessionId;
}

/** Returns true only the first time an order is marked paid (idempotent). */
export function markPaid(id: string): boolean {
  const order = store.get(id);
  if (!order || order.status === "paid") return false;
  order.status = "paid";
  return true;
}

The Server Action is the heart of the flow. It validates, prices, creates the session and redirects. In a Server Action, redirect() answers with status 303, which is what Stripe’s own Node.js example uses (res.redirect(303, session.url)) so that the browser follows with a GET.

app/shop/actions.ts: price lookup, validation, session creation and redirect
"use server";

import { redirect } from "next/navigation";
import { z } from "zod";
import { findProduct, listProducts } from "@/lib/catalog";
import { createOrder, attachSession, type OrderLine } from "@/lib/orders";
import { getStripe } from "@/lib/stripe";

const Quantity = z.coerce.number().int().min(0).max(10);

export async function startCheckout(formData: FormData) {
  // 1. The browser only says WHICH products and HOW MANY. Prices are never read from it.
  const lines: OrderLine[] = [];
  for (const { id } of listProducts()) {
    const parsed = Quantity.safeParse(formData.get(`qty-${id}`) ?? 0);
    if (!parsed.success) redirect("/shop?error=invalid");
    if (parsed.data === 0) continue;
    const product = findProduct(id)!;
    lines.push({ productId: id, quantity: parsed.data, unitAmount: product.unitAmount });
  }
  if (lines.length === 0) redirect("/shop?error=empty");

  // 2. Price the order on the server and remember it.
  const order = createOrder(lines, "eur");

  // 3. Create the Checkout Session on the server, from our own numbers.
  const baseUrl = process.env.APP_URL;
  if (!baseUrl) throw new Error("APP_URL is not set");
  const session = await getStripe().checkout.sessions.create({
    mode: "payment",
    client_reference_id: order.id,
    line_items: lines.map((l) => ({
      quantity: l.quantity,
      price_data: {
        currency: order.currency,
        unit_amount: l.unitAmount,
        product_data: { name: findProduct(l.productId)!.name },
      },
    })),
    success_url: `${baseUrl}/shop/success?order=${order.id}`,
    cancel_url: `${baseUrl}/shop?cancelled=1`,
  });
  attachSession(order.id, session.id);

  // 4. Send the browser to Stripe-hosted Checkout.
  if (!session.url) throw new Error("Checkout Session has no URL");
  redirect(session.url);
}

The page that posts to it contains no price inputs at all, and the form is a plain <form action>, so it works even before JavaScript has loaded. Under Cache Components the searchParams read must sit under <Suspense>: the first version of this page read it at the top and failed the build with blocking-prerender-dynamic; the structure below works in both caching models.

app/shop/page.tsx: ids and quantities only
import { Suspense } from "react";
import { listProducts } from "@/lib/catalog";
import { startCheckout } from "./actions";

const money = new Intl.NumberFormat("en", { style: "currency", currency: "EUR" });

async function ErrorNotice({ searchParams }: Pick<PageProps<"/shop">, "searchParams">) {
  const { error } = await searchParams;
  return error ? <p role="alert">Please choose between 1 and 10 copies.</p> : null;
}

export default function Shop({ searchParams }: PageProps<"/shop">) {
  return (
    <main>
      <h1>Shop</h1>
      <Suspense>
        <ErrorNotice searchParams={searchParams} />
      </Suspense>
      <form action={startCheckout}>
        {listProducts().map((p) => (
          <label key={p.id}>
            {p.name} ({money.format(p.unitAmount / 100)})
            <input type="number" name={`qty-${p.id}`} defaultValue={0} min={0} max={10} />
          </label>
        ))}
        <button type="submit">Pay with Stripe</button>
      </form>
    </main>
  );
}

The success page tells the visitor what our own records say, and nothing more:

app/shop/success/page.tsx: a landing page, not a proof of payment
import { Suspense } from "react";
import { getOrder } from "@/lib/orders";

async function OrderStatus({ searchParams }: Pick<PageProps<"/shop/success">, "searchParams">) {
  const { order: id } = await searchParams;
  const order = typeof id === "string" ? getOrder(id) : undefined;
  // The redirect proves nothing. Only the webhook marks an order as paid.
  return (
    <p>
      {order?.status === "paid"
        ? "Payment confirmed."
        : "We are waiting for the payment confirmation. This page does not mean the order is paid."}
    </p>
  );
}

export default function Success({ searchParams }: PageProps<"/shop/success">) {
  return (
    <main>
      <h1>Thank you</h1>
      <Suspense fallback={<p>Checking your order...</p>}>
        <OrderStatus searchParams={searchParams} />
      </Suspense>
    </main>
  );
}

The webhook is where an order becomes paid. Four details decide whether it is correct:

  • It reads the raw body with await request.text() and passes that exact string to stripe.webhooks.constructEvent with the signature header and the endpoint secret. The signature covers the bytes Stripe sent; parsing the JSON and re-serialising it, or any other manipulation of the body, makes verification fail. (In the Pages Router, an API Route must switch off the body parser with export const config = { api: { bodyParser: false } } to get the raw bytes; a Route Handler needs nothing special.) The library rejects timestamps older than five minutes by default.

  • Verification failures answer 400; a missing server secret answers 500 (our fault, so Stripe retries). Stripe retries failed deliveries for up to three days in live mode, so a handler must be safe to run more than once.

  • Fulfilment is idempotent: markPaid returns true only the first time. The same event can arrive twice, and checkout.session.completed and checkout.session.async_payment_succeeded can both occur for one order. In a real system make it a database constraint on the session id, not a Map.

  • The handler does not trust the event alone. It retrieves the session (as Stripe’s fulfilment guide describes), rejects an unpaid status (a delayed payment method will send async_payment_succeeded later) and compares the amount_total and currency with the order’s server-computed total.

app/api/stripe/webhook/route.ts: signature check on the raw body, then fulfilment
import type Stripe from "stripe";
import { getOrder, markPaid } from "@/lib/orders";
import { getStripe } from "@/lib/stripe";

async function fulfil(sessionId: string) {
  const stripe = getStripe();
  const session = await stripe.checkout.sessions.retrieve(sessionId, {
    expand: ["line_items"],
  });
  if (session.payment_status === "unpaid") return; // wait for async_payment_succeeded

  const order = session.client_reference_id ? getOrder(session.client_reference_id) : undefined;
  if (!order) {
    console.warn("webhook: unknown order for session", sessionId);
    return;
  }
  if (session.amount_total !== order.total || session.currency !== order.currency) {
    console.error("webhook: amount mismatch for order", order.id);
    return;
  }
  if (markPaid(order.id)) {
    console.log("fulfilled order", order.id); // ship goods, send e-mail, update stock
  } else {
    console.log("already fulfilled", order.id);
  }
}

export async function POST(request: Request) {
  const signature = request.headers.get("stripe-signature");
  const secret = process.env.STRIPE_WEBHOOK_SECRET;
  if (!secret) return new Response("Webhook not configured", { status: 500 });
  if (!signature) return new Response("Missing signature", { status: 400 });

  // The signature covers the exact bytes Stripe sent: read the raw text, not request.json().
  const rawBody = await request.text();

  let event: Stripe.Event;
  try {
    event = getStripe().webhooks.constructEvent(rawBody, signature, secret);
  } catch {
    return new Response("Invalid signature", { status: 400 });
  }

  switch (event.type) {
    case "checkout.session.completed":
    case "checkout.session.async_payment_succeeded":
      await fulfil(event.data.object.id);
      break;
    default:
      break; // acknowledge events we do not use
  }
  return Response.json({ received: true });
}

How this was tested without a Stripe account. No real key was used and no request left the machine. Two scratch-only aids stood in for Stripe:

  • A preload script (NODE_OPTIONS=--require <file>) replaced https.request for the host api.stripe.com with an in-process fake. For POST /v1/checkout/sessions it recorded the form body Stripe would have received and answered with a session object holding a url; for GET /v1/checkout/sessions/<id> it answered with the same session marked paid.

  • The webhook events were signed with the library’s own test helper, stripe.webhooks.generateTestHeaderString({ payload, secret }), using a random throw-away secret generated in the shell (openssl rand) and passed to the server only as an environment variable. The same helper is what Stripe’s Node.js README recommends for testing signing.

Before going live, repeat the flow against Stripe’s test mode with the Stripe CLI, which forwards real signed events to a local URL (stripe listen --forward-to localhost:3904/api/stripe/webhook; it prints the endpoint secret for that session) and with Stripe’s published test card numbers.

Results of next build and next start (previous model; the Cache Components build gave identical results, with /shop and /shop/success shown as ◐ in the route table):

# 1. The form is a plain POST. Extract the hidden action field, then post quantities
#    AND forged price fields:
$ curl -s -D- -o /dev/null -X POST localhost:3904/shop -F "$ACTION_FIELD=" \
    -F qty-dune=2 -F qty-solaris=1 -F price-dune=1 -F unit_amount=1
HTTP/1.1 303 See Other
Location: https://checkout.example.test/pay/cs_fake_1

# 2. What the server sent to Stripe (the fake recorded it): the catalogue prices, not 1
line_items[0][price_data][unit_amount]=1299   line_items[1][price_data][unit_amount]=999

# 3. Invalid quantities and an empty cart are rejected before any Stripe call
$ curl -s -D- -o /dev/null -X POST localhost:3904/shop -F "$ACTION_FIELD=" -F qty-dune=-3 | grep -i ^location
Location: /shop?error=invalid
$ curl -s -D- -o /dev/null -X POST localhost:3904/shop -F "$ACTION_FIELD=" | grep -i ^location
Location: /shop?error=empty
# 4. The success page before any webhook: not paid
$ curl -s "localhost:3904/shop/success?order=$ORDER_ID" | grep -o '<p>[^<]*</p>'
<p>We are waiting for the payment confirmation. This page does not mean the order is paid.</p>

# 5. Webhook cases (each signed with generateTestHeaderString)
no signature header            -> 400 Missing signature
valid signature                -> 200 {"received":true}     (server log: "fulfilled order <id>")
the same event again           -> 200 {"received":true}     (server log: "already fulfilled <id>")
signed with another secret     -> 400 Invalid signature
body edited after signing      -> 400 Invalid signature
server started without secret  -> 500 Webhook not configured

# 6. The success page after the valid webhook
<p>Payment confirmed.</p>

Under Cache Components the success page first sends the Checking your order…​ fallback and then the status, because the searchParams read is a dynamic hole in the prerendered shell.

Mapping the books' commerce concepts to this code. The headless-CMS catalogue (a Sanity or GraphCMS query in the books) becomes the server catalogue above; swap lib/catalog.ts for a CMS client that is server-only, ask it by id, and keep CMS write tokens out of the browser. The Context-based cart is still a good idea for the interface: keep it in a Client Component (see React state management) and let it hold only product ids and quantities, because anything in it can be edited by the user. The product listing pages of the books (static generation with fallback) are the Server Components and generateStaticParams of Static Site Generation; revalidating them when the CMS changes is the webhook pattern of Incremental Static Regeneration. Success and cancel pages are the success_url and cancel_url; the cancel URL here simply returns to the shop.

Production hardening that the example only hints at: authenticate the user (Authentication and Authorization) and attach the Stripe customer to the order; store orders, line items and a unique session id in a database; respond to the webhook quickly and move slow fulfilment to a queue; handle checkout.session.async_payment_failed for delayed payment methods; use Stripe Products and Prices instead of inline price_data once the catalogue is managed in the Dashboard; pin and upgrade the Stripe API version deliberately; and log failed signature checks, which usually mean a wrong secret, a proxy that rewrites the body, or an attacker. The general rules for entry points that anyone on the internet can call are in Data Security and CSP and Route Handlers and Backend for Frontend.

What Changed Recently

Table 5. Changes behind this page’s patterns, with versions
Version Change

Next.js 15

params, searchParams, cookies(), headers() and draftMode() became asynchronous; draftMode() was synchronous in 14 and earlier and could still be read synchronously in 15 with a deprecation warning. Route Handlers and fetch stopped being cached by default

Next.js 15.5

The Node.js runtime for Middleware became stable

Next.js 16.0

Middleware was renamed to Proxy (proxy.ts, export proxy) and defaults to the Node.js runtime; the edge runtime is not available in Proxy. Turbopack became the default bundler, which is why MDX plugins are passed by name. App Router apps use React 19.2 features, including View Transitions, through the bundled canary

Next.js 16.2

<Link transitionTypes> for typed view transitions

Next.js 16.3

experimental.useOffline and the useOffline hook from next/offline; Instant Navigations (Cache Components with partial prefetching) are still opt-in

Next.js 16.3 docs

The edge value of the runtime segment option is deprecated (the build warns on 16.3.8) and is rejected with cacheComponents; so is dynamicParams

stripe 23.0.0 (2026-09-30)

Pins API version 2026-09-30.endive. The client-side redirectToCheckout is gone from @stripe/stripe-js types since 8.0.0 (2025-10-01); the documented hosted-page flow redirects to session.url

@stripe/stripe-js 10.0.0

Latest on 2026-10-03; not needed by the hosted-page flow above

Where the Books Are Outdated

Both books were written for the Pages Router era (2022 and 2023). Treat their e-commerce chapters as a map of the concepts, and use the table below for the details.

Table 6. Outdated or unsafe points in the consulted books and their current replacements
In the books Today

Line items built from the cart (and its prices) posted by the browser; quantities not validated

Post only ids and quantities, validate them, read prices from the server catalogue, compare the paid amount in the webhook

A Stripe secret key exposed as a NEXT_PUBLIC_ variable, with the warning silenced

Secrets are unprefixed and read in server-only modules; only a publishable key may be public

The success redirect treated as proof of payment

A signed webhook marks the order paid; the success page only reports the status

loadStripe and redirectToCheckout in the browser

Create the session on the server and redirect to session.url; the types for redirectToCheckout were removed in @stripe/stripe-js 8.0.0

Session creation in a Pages Router API route, a hard-coded Stripe API version string

A Server Action or Route Handler; the installed stripe package pins its own API version

Product pages with getStaticProps, getStaticPaths and fallback, listings with getServerSideProps

Server Components, generateStaticParams, Cache Components or ISR (see the rendering pages of this section)

Cart and layout state in _app

A Client Component provider in the root layout, or a cart held on the server

Neither book covers MDX, Draft Mode, offline support, view transitions or multi-tenancy

Use the sections of this page; mid-2020s Next.js has first-class support for each

Middleware-era advice that the Edge runtime is the home of request interception

Proxy runs on Node.js since 16.0; runtime = 'edge' is deprecated

Pitfalls and Error Messages

Table 7. Errors met while building the examples
Symptom Cause and fix

Route segment config "dynamicParams" is not compatible with nextConfig.cacheComponents

Remove dynamicParams (and runtime) when cacheComponents is on; see dynamicParams

blocking-prerender-dynamic on /shop

searchParams, params, cookies() or headers() read outside <Suspense> under Cache Components; move the read into a child under <Suspense>, see the error page

500 with digest DYNAMIC_SERVER_USAGE from a route that built fine

The server was started with a different configuration than the build (here, cacheComponents driven by an environment variable that was set for next build and not for next start). Build and start must see the same configuration

Webhook always answers 400 Invalid signature in production

The wrong endpoint secret (test and live differ), a body that was parsed or re-encoded before verification, or a proxy that modifies it. Read await request.text() and nothing else first

Preview works with curl but not in a browser on http://localhost

The bypass cookie is Secure; SameSite=none; allow third-party cookies or use HTTPS

/tenants/acme is reachable on the main domain

The rewrite target is a normal route; return 404 from the Proxy for direct hits

Banner never shows with useOffline

The option is not set in experimental, or the code runs on the server or before hydration, where the hook returns false

Edge Runtime is deprecated warning

Delete export const runtime = 'edge'; see edge-runtime-deprecated