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
| Pattern | Built on | Router | Status in 16.3 |
|---|---|---|---|
|
App Router and Pages Router |
Stable. The Rust compiler ( |
|
|
App Router (Pages Router has |
Stable |
|
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 |
|
|
App Router |
Manifest and service worker are browser features. |
|
React |
App Router |
|
|
|
Both routers |
Deprecated. Proxy runs on Node.js in 16 |
|
A streaming Route Handler and a Client Component |
Both routers |
Plain web APIs. Libraries are optional |
|
Server Action, Route Handler, Stripe Checkout |
Both routers |
Stripe API version |
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.
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:
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;
}
Source: MDX: add an mdx-components.tsx file and mdx-components.js
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:
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/)
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):
export const meta = { title: "Welcome post", author: "Ada" }
# Welcome
Posts live in `content/` and are imported by slug.
Source: MDX: rendering MDX, MDX: using dynamic imports and MDX: frontmatter
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.
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}`);
}
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:
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
GETis acceptable only because a CMS opens the URL in a new tab; the guide says operations that affect later requests should usePOSTotherwise, 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().isEnabledcan be read inside a'use cache'scope (the component then re-executes on every draft request and nothing is saved), butenable()anddisable()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/apithat callsres.setDraftMode({ enable: true }), andgetStaticPropsreceivescontext.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.
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|.*\\..*).*)"],
};
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 innotFound(). -
Static where possible. The tenant list feeds
generateStaticParams, so/tenants/acmeis prerendered (●). WithdynamicParams = falsean unknown tenant is a404without rendering. Under Cache ComponentsdynamicParamsis not allowed (remove it), and the unknown tenant still ended in404throughnotFound(); the example returned404in 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-tenantheader 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:
| Block | What it does | In Next.js |
|---|---|---|
Web app manifest |
Name, icons, start URL and display mode, so the browser can offer installation |
|
Service worker |
A script that the browser runs in the background: push messages, request interception, caching |
A plain file in |
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 |
Connectivity-aware navigation |
Keeps a failed navigation, prefetch or Server Action pending and retries it when the network returns |
|
The manifest. Because it is a metadata file convention, it is typed and served for you:
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" }],
};
}
Source: PWAs: creating the web app manifest and manifest.json
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:
// 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("/"));
});
"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:
"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.
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>
);
}
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-classthat 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-motionin 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:
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" });
}
Source: Route Segment Config: runtime and Edge Runtime
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:
| Topic | Fact |
|---|---|
Status |
|
Proxy |
|
Limits |
No native Node.js APIs (for example the file system), no |
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 |
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:
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" },
});
}
"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>
);
}
Source: route.js and MDN: Streams API
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 answers400otherwise. 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-onlymodule; 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; aPOSThandler is never cached by Next.js in either model. -
Stop work when the user leaves. The loop checks
request.signalso 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-js8.0.0, 2025-10-01).
The corrected flow keeps every decision that matters on the server:
The same flow as a checklist, with the file that implements each step:
| Step | What happens | File |
|---|---|---|
1 |
The form posts only product ids and quantities. A price field in the request is simply never read |
|
2 |
Quantities are validated (integer, 0 to 10), prices come from a |
|
3 |
The Checkout Session is created on the server and the user is redirected to |
|
4 |
Stripe sends a signed |
|
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 |
|
6 |
The success page only reports the status in our own database; it does not prove payment |
|
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.
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.
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;
}
Source: stripe-node README (lazy client creation) and Stripe: best practices for managing API keys
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.
"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.
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:
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 tostripe.webhooks.constructEventwith 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 withexport 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 answers500(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:
markPaidreturnstrueonly the first time. The same event can arrive twice, andcheckout.session.completedandcheckout.session.async_payment_succeededcan both occur for one order. In a real system make it a database constraint on the session id, not aMap. -
The handler does not trust the event alone. It retrieves the session (as Stripe’s fulfilment guide describes), rejects an
unpaidstatus (a delayed payment method will sendasync_payment_succeededlater) and compares theamount_totaland currency with the order’s server-computed total.
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 });
}
Source: Stripe: accept a payment (Stripe-hosted page), Stripe API: create a Checkout Session, Stripe: fulfill orders, Stripe: webhooks and Stripe: check the webhook signatures; Next.js: Server Actions, route.js and Server Actions and Mutations
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>) replacedhttps.requestfor the hostapi.stripe.comwith an in-process fake. ForPOST /v1/checkout/sessionsit recorded the form body Stripe would have received and answered with a session object holding aurl; forGET /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
| Version | Change |
|---|---|
Next.js 15 |
|
Next.js 15.5 |
The Node.js runtime for Middleware became stable |
Next.js 16.0 |
Middleware was renamed to Proxy ( |
Next.js 16.2 |
|
Next.js 16.3 |
|
Next.js 16.3 docs |
The |
|
Pins API version |
|
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.
| 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 |
Secrets are unprefixed and read in |
The success redirect treated as proof of payment |
A signed webhook marks the order paid; the success page only reports the status |
|
Create the session on the server and redirect to |
Session creation in a Pages Router API route, a hard-coded Stripe API version string |
A Server Action or Route Handler; the installed |
Product pages with |
Server Components, |
Cart and layout state in |
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; |
Pitfalls and Error Messages
| Symptom | Cause and fix |
|---|---|
|
Remove |
|
|
|
The server was started with a different configuration than the build (here, |
Webhook always answers |
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 |
Preview works with |
The bypass cookie is |
|
The rewrite target is a normal route; return |
Banner never shows with |
The option is not set in |
|
Delete |