Static exports
Turn a Next.js app into plain files with output: 'export', see what still works, what stops the build, and how a static host serves the out folder.
- 01Export an app as filesSet output: 'export', read the out folder, and say why Next.js 16.4.3 needs Cache Components off for it.
- 02Tell what needs a serverKnow which features stop the build, which build but never run on a static host, and what to use instead.
- 03Serve the files correctlyPick images that work without a server, choose trailingSlash for your host, and set up a single-page app's fallback.
The tea shop as a folder of files
Deploying Next.js named four ways to run an app. One of them has no server of yours at all: a static export. With it, next build runs your code once and writes every page as a plain file. Any static host, a web server that only sends files (nginx, Amazon S3, GitHub Pages), can then put the site online. The site you're reading is a Next.js app exported this way.
Here is the tea shop's menu site, built as an export. It has a home page, a list of teas, a page for each tea, a JSON file of the teas, and a search page. This example is a real Next.js 16.4.3 project. It was built with next build; then its out folder was served as plain files, by a small file server written for the recordings. It simulates the try_files rule from the Next.js docs' nginx example; it isn't nginx. Nothing else ran. Click a file in the tree to read it.
next.config.tsimport type { NextConfig } from "next"; const nextConfig: NextConfig = { output: "export",}; export default nextConfig;$ next build▲ Next.js 16.4.3 (Turbopack) Creating an optimized production build ...✓ Compiled successfully Running TypeScript ... Finished TypeScript ... Collecting page data ...✓ Generating static pages (9/9) Finalizing page optimization ... Route (app)┌ ○ /├ ○ /_not-found├ ○ /search├ ○ /teas├ ○ /teas.json└ /teas/[slug] ├ ● /teas/mint ├ ● /teas/green └ ● /teas/oolong ○ (Static) prerendered as static content● (SSG) prerendered as static HTML (uses generateStaticParams) # What out holds: 49 files, 721.1 kB (deeper folders added up)out/404.html 7.4 kBout/__next.__PAGE__.txt 2.8 kBout/__next._full.txt 3.1 kBout/__next._tree.txt 110 bytesout/_next/static/ 591.7 kB, 12 filesout/_not-found.html 7.4 kBout/_not-found.txt 4.6 kBout/_not-found/__next._full.txt 4.6 kBout/_not-found/__next._not-found.__PAGE__.txt 4.0 kBout/_not-found/__next._tree.txt 164 bytesout/index.html 5.1 kBout/index.txt 3.1 kBout/search.html 5.8 kBout/search.txt 3.7 kBout/search/__next._full.txt 3.7 kBout/search/__next._tree.txt 159 bytesout/search/__next.search.__PAGE__.txt 3.2 kBout/teas.html 6.0 kBout/teas.json 139 bytesout/teas.txt 3.8 kBout/teas/__next._full.txt 3.8 kBout/teas/__next._tree.txt 157 bytesout/teas/__next.teas.__PAGE__.txt 3.3 kBout/teas/green.html 6.1 kBout/teas/green.txt 4.0 kBout/teas/green/ 7.5 kB, 3 filesout/teas/mint.html 6.1 kBout/teas/mint.txt 4.0 kBout/teas/mint/ 7.5 kB, 3 filesout/teas/oolong.html 6.1 kBout/teas/oolong.txt 4.0 kBout/teas/oolong/ 7.5 kB, 3 files # out, served by the recorder's file server, simulating: try_files $uri $uri.html $uri/ =404GET / 200 out/index.htmlGET /teas 200 out/teas.htmlGET /teas/mint 200 out/teas/mint.htmlGET /teas.json 200 out/teas.jsonGET /teas/earl-grey 404 out/404.htmlGET / 200 out/index.htmlHEAD /teas 200 out/teas.htmlHEAD / 200 out/index.htmlHEAD /search 200 out/search.htmlGET /search/__next._tree.txt?_rsc=5CB68i4pnAekjehf 200 out/search/__next._tree.txtGET /teas/__next._tree.txt?_rsc=5CB68i4pnAekjehf 200 out/teas/__next._tree.txtGET /__next._tree.txt?_rsc=5CB68i4pnAekjehf 200 out/__next._tree.txtGET /favicon.ico 404 out/404.htmlGET /__next.__PAGE__.txt?_rsc=8nTjltbQWvg6NcbE 200 out/__next.__PAGE__.txtGET /teas/__next.teas.__PAGE__.txt?_rsc=ZU2-9kdjCharDNZa 200 out/teas/__next.teas.__PAGE__.txtGET /search/__next.search.__PAGE__.txt?_rsc=JpRJtnymxutXs-x0 200 out/search/__next.search.__PAGE__.txtHEAD /teas/mint 200 out/teas/mint.htmlHEAD /teas/green 200 out/teas/green.htmlHEAD /teas/oolong 200 out/teas/oolong.htmlGET /teas/mint/__next._tree.txt?_rsc=pnTqb7yk4B8XX_5N 200 out/teas/mint/__next._tree.txtGET /teas/oolong/__next._tree.txt?_rsc=pnTqb7yk4B8XX_5N 200 out/teas/oolong/__next._tree.txtGET /teas/green/__next._tree.txt?_rsc=pnTqb7yk4B8XX_5N 200 out/teas/green/__next._tree.txtGET /teas/mint/__next.teas.$d$slug.__PAGE__.txt?_rsc=jaySoL3AIAivF3Vf 200 out/teas/mint/__next.teas.$d$slug.__PAGE__.txtGET /teas/oolong/__next.teas.$d$slug.__PAGE__.txt?_rsc=jaySoL3AIAivF3Vf 200 out/teas/oolong/__next.teas.$d$slug.__PAGE__.txtGET /teas/green/__next.teas.$d$slug.__PAGE__.txt?_rsc=jaySoL3AIAivF3Vf 200 out/teas/green/__next.teas.$d$slug.__PAGE__.txtGET /teas.json 200 out/teas.jsonOpen the Terminal tab. It shows three things, in order:
- What
next buildprinted. Every route is○, "prerendered as static content", or●, "prerendered as static HTML (uses generateStaticParams)": the three tea pages. - What the
outfolder held afterwards: 49 files, likeindex.html,teas/mint.htmlandteas.json. - The host's rule and its log: each address it was asked for, the status it answered, and the file it sent.
GET /teas/mintgotout/teas/mint.html.
The Browser and Network tabs hold the five answers the host sent, including the 404 for a tea the shop doesn't sell. Steps is real Chrome clicking through the site.
next build runs your code once and writes every page into out as a file. After that, a static host only finds the file an address asks for and sends it. Nothing of yours runs on the host.A tea shop prints a menu booklet each season. A rack by the door hands booklets to anyone who walks in; the rack can't cook, and it can't answer a question. Everything in the booklet was decided at the print shop. A guest who wants to order fills in the form at their table. To change a price, the shop prints the booklet again.
- In real life: The print shop prints the season's booklet
- In Next.js:
next buildwrites theoutfolder - In real life: A rack by the door hands booklets out
- In Next.js: A static host sends files
- In real life: The booklet's index sends you to page 12
- In Next.js: The host finds the file for an address
- In real life: Prices change only in the next print run
- In Next.js: Pages change only at the next build
- In real life: The order form you fill in at your table
- In Next.js: A Client Component, running in the browser
A static export runs your server code once, during the build, and keeps only its results, as files. What can be decided then works on any host. What needs a request, like a cookie, a form posted to the server, or a page made later, needs a server, and an export has none.
output: 'export' and the out folder
One option turns a project into an export. This is the shop's whole next.config.ts:
import type { NextConfig } from "next"; const nextConfig: NextConfig = { output: "export",}; export default nextConfig; Line 4 is the option: output: "export". With it, next build does its usual work, then copies what the pages need into a folder called out. Notice what's missing: cacheComponents and partialPrefetching, the two lines a new project has. With them, the export stops.
Before Next.js 14, the command was next export, run after next build. Next.js 13.3 deprecated it and 14.0 removed it: today the option in the config is the only way. Each row below is one kind of file the recorded out folder held:
| Files | What they are | Who asks for them |
|---|---|---|
index.html, teas.html, teas/mint.html | One HTML page for each route the build made. | A browser, on a direct visit or a reload. |
404.html | The not-found page, made from the app's not-found (here Next.js's own). | The host, when no file matches the address. |
teas.json | The JSON Route Handler's answer, written as a file. | The search box, with fetch in the browser. |
index.txt, teas.txt, teas/mint.txt | Each page's whole RSC payload: what React needs to show the page without loading new HTML. | Next.js's router, when it moves to a page it didn't prefetch. |
teas/__next._tree.txt, teas/__next.teas.__PAGE__.txt | The same payload in pieces: the route's tree, and the page's own part (the build's internal names). | The router, when it prefetches a link. |
_next/static/ | The JavaScript and CSS files, with hashes in their names. | Every page, through its <script> and <link> tags. |
The .html files are what a person gets when they type an address or reload. The .txt files hold the same pages as an RSC payload: React's description of what a page shows, which the router uses to change the page in place, without loading new HTML (Linking and navigating). Their __next.… names are this build's internals, not a promise of the API: Next.js's own code says so. Read them, but don't write code that depends on them.
Why an export runs without Cache Components
New projects start with Cache Components on (Cache Components). It lets one page mix a static shell with parts made for each request, which Next.js streams in. A static export has no request and no stream, so in Next.js 16.4.3 the two don't mix. Try it: each button is the shop with Cache Components on, changed a little more each time, and the last one is the other way round.
next.config.tsimport type { NextConfig } from "next"; const nextConfig: NextConfig = { cacheComponents: true, partialPrefetching: true, output: "export",}; export default nextConfig;$ next build▲ Next.js 16.4.3 (Turbopack)- Cache Components enabled- Partial Prefetching enabled Creating an optimized production build ... > Build error occurredError: Turbopack build failed with 1 error:./app/teas.json/route.ts:3:14Error: Route segment config "dynamic" is not compatible with `nextConfig.cacheComponents`. Please remove it. 1 | import { getTeas } from "@/lib/teas"; 2 |> 3 | export const dynamic = "force-static"; | ^^^^^^^ 4 | 5 | export async function GET() { 6 | return Response.json(await getTeas()); Ecmascript file had an error at ignore-listed framesLines 4 and 5 are what create-next-app writes; line 6 adds the export. The build stops before it renders anything: the JSON route's dynamic line isn't allowed with Cache Components.
What each build printed:
- On, as in new projects: the build stops before it renders a page.
Route segment config "dynamic" is not compatible with `nextConfig.cacheComponents`. The JSON route'sdynamicline belongs to the older model. - On, without that line: now the export stops on the same route, because an export needs that very line. The export wants the line that Cache Components forbids.
- On, without the JSON route: every page prerenders (
✓ Generating static pages (8/8)), then:Invariant: PPR cannot be enabled in export mode. That's the real reason. Partial Prerendering (PPR), the shell-and-holes model Cache Components is built on, can't be written as plain files, and the export refuses it. - Off, with one "use cache": the build stops on line 4 of
app/page.tsx."use cache"needs thecacheComponentsflag.
So an export project in 16.4.3 turns Cache Components off. That puts it on the older model, the one before Cache Components (Caching before Cache Components):
- No
"use cache",cacheLifeorcacheTag. Nothing needs them: every page is made once, during the build. - Route segment config, like
export const dynamic = "force-static", is how you tell Next.js a route is static. The JSON route needs it. - In an export, every page must be made during the build, so a page that reads the request stops it.
What still works: everything made during the build
A static export isn't a smaller Next.js. It's the same App Router, where everything that runs on the server runs during the build. Look at what the shop does there:
- Server Components fetch data.
lib/teas.tsasks the supplier's API for the price list on line 5 (Server Components). The recording kept every request the supplier got: 2 of them, all duringnext build, and none afterwards. The supplier here is a stand-in server the recorder started; a real shop calls its real API the same way. - Dynamic routes, from a list.
app/teas/[slug]/page.tsxreturns the teas on lines 5 to 8, and the build writes one file for each:teas/mint.html,teas/green.html,teas/oolong.html(generateStaticParams). A tea that's not in the list has no file. The recorded/teas/earl-greygotout/404.html, with status 404. - Metadata. The root layout's title template on line 5 of
app/layout.tsx, and each tea'sgenerateMetadata, ran during the build. The recorded/teas/mintcame with the titleMint tea | Tea shop(The Metadata API). - A GET Route Handler, as a file. The JSON route below runs once, and its answer is saved as
out/teas.json.
import { getTeas } from "@/lib/teas"; export const dynamic = "force-static"; export async function GET() { return Response.json(await getTeas());} The folder's name, teas.json, is the route's address. Line 3 marks the handler force-static: run it during the build and keep its answer. In an export, every Route Handler needs that line, or the build stops. Line 5 can only be GET, and line 6 can't read the request: there is none. The recorded host sent the file with Content-Type: application/json, picked from its name (Route Handlers).
next build, and its results become files. The host only sends them. Everything that happens after a visitor arrives runs in the browser.In the browser: links, search and fetch
Files can't react to a visitor. The browser can. An export still ships React and Next.js's router to the browser, so Client Components run, and links change the page in place. Here is the same recording, on the Steps tab: real Chrome opened /, clicked Teas, then Mint tea, then Search, and typed gr.
next.config.tsimport type { NextConfig } from "next"; const nextConfig: NextConfig = { output: "export",}; export default nextConfig;Each step was taken in real Chrome against the out folder, served by the recorder's file server (a simulation of a static host's rules). Pick one to see the page after it.
- Mint tea
- Green tea
- Oolong tea
- Focus
- the link “Teas”
- Scrolled
- 0 px down
- Announced to screen readers
Teas | Tea shop
Requests the page made during this step (9), without the files it loaded
HEAD/teas/mint200text/htmlHEAD/teas/green200text/htmlHEAD/teas/oolong200text/htmlGET/teas/mint/__next._tree.txt200text/plain- rsc
- 1
- next-router-prefetch
- 1
- next-router-segment-prefetch
- /_tree
- next-url
- /teas
GET/teas/green/__next._tree.txt200text/plain- rsc
- 1
- next-router-prefetch
- 1
- next-router-segment-prefetch
- /_tree
- next-url
- /teas
GET/teas/oolong/__next._tree.txt200text/plain- rsc
- 1
- next-router-prefetch
- 1
- next-router-segment-prefetch
- /_tree
- next-url
- /teas
GET/teas/mint/__next.teas.$d$slug.__PAGE__.txt200text/plain- rsc
- 1
- next-router-prefetch
- 1
- next-router-segment-prefetch
- /teas/$d$slug/__PAGE__
- next-url
- /teas
GET/teas/oolong/__next.teas.$d$slug.__PAGE__.txt200text/plain- rsc
- 1
- next-router-prefetch
- 1
- next-router-segment-prefetch
- /teas/$d$slug/__PAGE__
- next-url
- /teas
GET/teas/green/__next.teas.$d$slug.__PAGE__.txt200text/plain- rsc
- 1
- next-router-prefetch
- 1
- next-router-segment-prefetch
- /teas/$d$slug/__PAGE__
- next-url
- /teas
Pick each step and read its requests:
- Open /: one page load,
GET /. Then the router prefetches the three links on the page. For each, it sendsHEAD /teasfirst, which checks the page exists, then asks for files like/teas/__next._tree.txtand/teas/__next.teas.__PAGE__.txt. - Click Teas: no request for the teas page. The router already had it. The requests in this step are new prefetches, for the three tea pages the teas page links to.
- Click Mint tea: no requests at all. The page changed in place.
- Click Search: one request,
GET /teas.json, sent by the search box below.
With a Next.js server, the router asks for these pieces with its own request headers, and the server reads them. A static host reads no headers, so in an export the router puts that information in the file name instead. Next.js's code explains it, and calls the names an internal detail (segment-cache/cache.ts, lines 1851 to 1899). This is the search box:
"use client"; import { useEffect, useState } from "react";import type { Tea } from "@/lib/teas"; export default function TeaSearch() { const [teas, setTeas] = useState<Tea[]>([]); const [text, setText] = useState(""); useEffect(() => { let ignore = false; fetch("/teas.json") .then((response) => response.json()) .then((list: Tea[]) => { if (!ignore) setTeas(list); }); return () => { ignore = true; }; }, []); const found = teas.filter((tea) => tea.name.toLowerCase().includes(text.toLowerCase())); return ( <> <input placeholder="Search teas" value={text} onChange={(event) => setText(event.target.value)} /> <ul> {found.map((tea) => ( <li key={tea.slug}>{tea.name}</li> ))} </ul> </> );} Line 1 makes it a Client Component, so its code runs in the browser too. The Effect on lines 9 to 19 asks for /teas.json once the page is on screen (Effects). Lines 10, 14 and 17 make sure an answer that arrives after the component is gone is thrown away. Line 20 keeps the teas whose names include what the visitor typed, so step 6 shows only Green tea. The list is a file; the filtering happens on the visitor's computer (Client-side fetching).
Builds that stop: code that needs a request
Some code only makes sense when a request comes in. In an export, Next.js stops the build when it finds it, and says why. Each button below is the exported shop with one file changed. Before you click, guess what each error will name.
app/page.tsximport { headers } from "next/headers"; export default async function Home() { const language = (await headers()).get("accept-language"); return ( <main> <h1>Tea shop</h1> <p>Your browser prefers: {language}</p> </main> );}$ next build▲ Next.js 16.4.3 (Turbopack) Creating an optimized production build ...✓ Compiled successfully Running TypeScript ... Finished TypeScript ... Collecting page data ...Error occurred prerendering page "/". Read more: https://nextjs.org/docs/messages/prerender-errorError: Route / with `dynamic = "error"` couldn't be rendered statically because it used `headers()`. See more info here: https://nextjs.org/docs/app/building-your-application/rendering/static-and-dynamic#dynamic-renderingExport encountered an error on /page: /, exiting the build.⨯ Next.js build worker exited with code: 1 and signal: nullLine 4 reads the request's accept-language header. During the build there's no request, so the build stops on /.
The six errors, word for word, and what to do instead:
- headers():
Route / with `dynamic = "error"` couldn't be rendered statically because it used `headers()`.cookies()stops it the same way (Request-time APIs). In an export, read what you can in the browser:navigator.languageinstead of the language header, anddocument.cookieonly for preferences that aren't secret, like a chosen theme. A sign-in cookie should beHttpOnly, which the browser's JavaScript can't read on purpose: anything that checks who is asking needs a server, or an outside API that checks the visitor itself. - A Server Action:
Server Actions are not supported with static export.A Server Action is a POST to your server (Server Actions). Send the form to a service that has a server instead, from a Client Component. - No generateStaticParams:
Page "/teas/[slug]" is missing "generateStaticParams()" so it cannot be used with "output: export" config.List every tea the build should write. - dynamicParams = true:
"dynamicParams: true" cannot be used with "output: export".That setting asks for unlisted teas to be made on request (Dynamic routes). Remove it: an export only has the listed ones. - A route without force-static:
export const dynamic = "force-static"/export const revalidate not configured on route "/teas.json" with "output: export".Add the line. - sitemap.ts: the same error, for
/sitemap.xml. Asitemap.tsorrobots.tsis a Route Handler too, so it needsexport const dynamic = "force-static"in an export (Sitemaps, robots.txt and structured data).
The docs' list of unsupported features also names Draft Mode, intercepting routes and Route Handlers that read the request. They all need a request, so they all need a server.
Builds that pass, and features that never run
Some features need a server just as much, but the build doesn't stop for them. It warns, or says nothing, and writes out. Then the host can't do what the code asks for. These four builds were each served as plain files, and the Network tab shows what the host answered:
proxy.tsimport { NextResponse, type NextRequest } from "next/server"; // The old menu address shows the teas page.export function proxy(request: NextRequest) { return NextResponse.rewrite(new URL("/teas", request.url));} export const config = { matcher: "/menu" };GET /menu404
- Response headers
Content-Typetext/html
The HTML that arrived, one element per line, with the code of its 10 scripts left out
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="preload" as="script" fetchpriority="low" href="/_next/static/chunks/1x6obpmlu1_c2.js">
<script src="/_next/static/chunks/2r4s4h5xul1n-.js" async=""></script>
<script src="/_next/static/chunks/3wbm-994r45kr.js" async=""></script>
<script src="/_next/static/chunks/1-9dyrc39prri.js" async=""></script>
<script src="/_next/static/chunks/turbopack-3-ry682tkyiqk.js" async=""></script>
<script src="/_next/static/chunks/27gjg8e11zukd.js" async=""></script>
<meta name="robots" content="noindex">
<title>Tea shop</title>
<title>404: This page could not be found.</title>
<script src="/_next/static/chunks/0cz1d0mv5g_q7.js" nomodule=""></script>
</head>
<body>
<div hidden="">
<!--$-->
<!--/$-->
</div>
<nav><a href="/">Home</a> <a href="/teas">Teas</a> <a href="/search">Search</a></nav>
<div style="font-family:system-ui,"Segoe UI",Roboto,Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji";height:100vh;text-align:center;display:flex;flex-direction:column;align-items:center;justify-content:center">
<div>
<style>body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}</style>
<h1 class="next-error-h1" style="display:inline-block;margin:0 20px 0 0;padding:0 23px 0 0;font-size:24px;font-weight:500;vertical-align:top;line-height:49px">404</h1>
<div style="display:inline-block">
<h2 style="font-size:14px;font-weight:400;line-height:49px;margin:0">This page could not be found.</h2>
</div>
</div>
</div>
<!--$-->
<!--/$-->
<script id="_R_">…</script>
<script src="/_next/static/chunks/1x6obpmlu1_c2.js" async=""></script>
<script>…</script>
<script>…</script>
</body>
</html>The build passes with a warning that the export disables middleware. The host answers /menu with 404: nothing runs the proxy.
- proxy.ts (Proxy): the build printed
ƒ Proxy (Middleware)and a warning,Statically exporting a Next.js application via `next export` disables API routes and middleware.On the host,/menuwas a 404: line 5's rewrite never ran. - redirects() (Redirects): two warnings, ending with
rewrites, redirects, and headers are not applied when exporting your application, detected (redirects)./menuwas a 404. The export's code gives the same warning forrewrites()andheaders()(export/index.ts, lines 248 to 258): set them up in your host's own settings instead. - revalidate = 3600 (Incremental Static Regeneration): no warning. The route table printed
1hnext to/teas, as if the page would be made again every hour. On a static host, nothing makes it again; the file changes at your next build and deploy. - next/image (Optimizing images): no warning, and the photo was a 404. Images get a section of their own, below.
Each row is one feature that needs a server, what its recorded build did, and what a visitor got:
| Feature | What next build did | What a visitor gets |
|---|---|---|
headers() or cookies() in a page | Stops: the route couldn't be rendered statically. | Nothing: there's no site to visit. |
| A Server Action | Stops: Server Actions are not supported with static export. | Nothing: there's no site to visit. |
A dynamic route without generateStaticParams | Stops: the page is missing generateStaticParams(). | Nothing: there's no site to visit. |
dynamicParams = true | Stops: it cannot be used with output: export. | Nothing: there's no site to visit. |
A GET Route Handler (or sitemap.ts) without force-static | Stops: dynamic = "force-static" is not configured. | Nothing: there's no site to visit. |
proxy.ts | Builds, with a warning that the export disables middleware. | A 404 for /menu: nothing runs the proxy. |
redirects() in next.config.ts | Builds, with two warnings that redirects aren't applied. | A 404 for /menu: nothing sends the redirect. |
export const revalidate = 3600 | Builds, with no warning. The table still says 1h. | The build's file, until the next build. |
next/image with the default loader | Builds, with no warning. | A 404 for the photo: there's no image optimizer. |
Read each row as one recording: the first five never get as far as a site, and the last four get a site that silently does less than its code says. A passing build is not proof; check what the host answers.
Images in a static export
next/image doesn't point the browser at your photo. Its default loader, the function that writes each image address, points at /_next/image: Next.js's image optimizer, which resizes the photo on a Next.js server. A static host has no such thing. Here is the home page with a photo:
import Image from "next/image"; export default function Home() { return ( <main> <h1>Tea shop</h1> <Image src="/mint-tea.jpg" alt="A cup of mint tea" width={480} height={320} /> </main> );} Line 7 asks for /mint-tea.jpg, 480 by 320. The three versions below differ only in next.config.ts. In the Network tab, open the HTML and find the img tag, then pick the second answer, if there is one:
app/page.tsximport Image from "next/image"; export default function Home() { return ( <main> <h1>Tea shop</h1> <Image src="/mint-tea.jpg" alt="A cup of mint tea" width={480} height={320} /> </main> );}GET /200
- Response headers
Content-Typetext/html
The HTML that arrived, one element per line, with the code of its 11 scripts left out
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="preload" as="script" fetchpriority="low" href="/_next/static/chunks/1ckvzn7sfprko.js">
<script src="/_next/static/chunks/2bzlqb104tejm.js" async=""></script>
<script src="/_next/static/chunks/216iyidhmrh3u.js" async=""></script>
<script src="/_next/static/chunks/01guna706fea3.js" async=""></script>
<script src="/_next/static/chunks/turbopack-3-ry682tkyiqk.js" async=""></script>
<script src="/_next/static/chunks/27gjg8e11zukd.js" async=""></script>
<script src="/_next/static/chunks/0zy_ty-3aie0u.js" async=""></script>
<title>Tea shop</title>
<script src="/_next/static/chunks/0cz1d0mv5g_q7.js" nomodule=""></script>
</head>
<body>
<div hidden="">
<!--$-->
<!--/$-->
</div>
<nav><a href="/">Home</a> <a href="/teas">Teas</a> <a href="/search">Search</a></nav>
<main>
<h1>Tea shop</h1>
<img alt="A cup of mint tea" loading="lazy" width="480" height="320" decoding="async" data-nimg="1" style="color:transparent" srcset="/_next/image?url=%2Fmint-tea.jpg&w=640&q=75 1x, /_next/image?url=%2Fmint-tea.jpg&w=1080&q=75 2x" src="/_next/image?url=%2Fmint-tea.jpg&w=1080&q=75">
</main>
<!--$-->
<!--/$-->
<script id="_R_">…</script>
<script src="/_next/static/chunks/1ckvzn7sfprko.js" async=""></script>
<script>…</script>
<script>…</script>
</body>
</html>The img tag points at /_next/image, which needs a Next.js server. The second answer is the host's 404.
- The default loader: the build passed. The tag's
srcis/_next/image?url=%2Fmint-tea.jpg&w=1080&q=75, and the host answered 404. That's what Next.js 16.4.3 did with this App Router shop; the docs list the default loader as unsupported in an export either way. The export does have a check that stops the build (export/index.ts, lines 412 to 436), andoutput: "export"runs it: the build calls the export withbuildExport: false(build/index.ts, lines 1079 to 1082). But the check fires only when the build has markednext/imageas used, and in this version the build sets that mark only while it checks Pages Router pages (build/index.ts, lines 2793 to 2806). With App Router pages alone, nothing stopped it. - unoptimized: true: the tag points at
/mint-tea.jpg, the file frompublic, and the host sent it as it is: 1600 by 1067 pixels for a 480-pixel space. It works, but every visitor downloads the full photo. Make the files the right size before they go intopublic. - A custom loader: the tag's addresses point at the shop's image service, one for each width the page needs. The service resizes; the host only sends the page.
import type { ImageLoaderProps } from "next/image"; // The shop's image service resizes photos: it reads the width and quality from the address.export default function teaImages({ src, width, quality }: ImageLoaderProps) { return `https://images.teashop.example${src}?w=${width}&q=${quality ?? 75}`;} loaderFile on line 5 of next.config.ts names this file. Next.js calls its function, on line 4, for each image and width, with the src, the width and the quality. Line 5 returns an address on the image service, here a made-up one, images.teashop.example, with the width and quality in its query. Real services, like Cloudinary or imgix, each read their own address format, so the function follows theirs.
How a static host finds /teas/mint
The links on the teas page say /teas/mint, but the file is teas/mint.html. The host has to connect the two, and hosts don't all do it the same way. Next.js gives you one setting for this, trailingSlash, and the docs give one nginx example. Here it is, complete, with the shop's folder and its /teas/ pages in place of the docs' /blog/:
server { listen 80; server_name teashop.example; root /var/www/tea-shop/out; location / { try_files $uri $uri.html $uri/ =404; } # This is necessary when `trailingSlash: false`. # You can omit this when `trailingSlash: true`. location /teas/ { rewrite ^/teas/(.*)$ /teas/$1.html break; } error_page 404 /404.html; location = /404.html { internal; }} It has three parts:
- Line 5 points nginx at the
outfolder, and line 8 is the general rule: try the address as a file ($uri), then with.htmladded ($uri.html), then as a folder with anindex.html($uri/), and answer 404 if none is there. - Lines 13 to 15 are for the tea pages. Line 14 rewrites
/teas/mintto/teas/mint.html. The comment on lines 11 and 12 is the docs': the block is needed withtrailingSlash: false, and can go withtrue. Read line 14's pattern before you copy it:(.*)also matches the router's prefetch files, like/teas/__next._tree.txt, and would add.htmlto them too. Narrow it to page addresses, and test it on your own server. - Line 17 sends
404.htmlfor any 404, and lines 18 to 20 mark that fileinternal: nginx uses it for errors, and a visitor can't ask for it by name.
The shop's recordings don't run nginx. Their file server simulates only the try_files line and the 404.html page, so they show what those rules mean for this out folder, not how nginx itself behaves.
try_files $uri $uri.html $uri/ =404, the host looks in order. out/teas/mint is a folder, so the first try fails. Adding .html finds the page. A host without that second try answers 404.Now the same folder on three hosts. Each version's Terminal ends with the host's log:
next.config.tsimport type { NextConfig } from "next"; const nextConfig: NextConfig = { output: "export",}; export default nextConfig;$ next build▲ Next.js 16.4.3 (Turbopack) Creating an optimized production build ...✓ Compiled successfully Running TypeScript ... Finished TypeScript ... Collecting page data ...✓ Generating static pages (9/9) Finalizing page optimization ... Route (app)┌ ○ /├ ○ /_not-found├ ○ /search├ ○ /teas├ ○ /teas.json└ /teas/[slug] ├ ● /teas/mint ├ ● /teas/green └ ● /teas/oolong ○ (Static) prerendered as static content● (SSG) prerendered as static HTML (uses generateStaticParams) # out, served by the recorder's file server, simulating: try_files $uri $uri/ =404GET /teas/mint 404 out/404.htmlGET /teas/mint.html 200 out/teas/mint.htmlThe host tries the exact name, then a folder's index.html. /teas/mint is a 404; /teas/mint.html works, but no link uses it.
Each row is one of the three hosts, and what it sent for the tea page:
| The host's rule | trailingSlash | What /teas/mint gets | Notes |
|---|---|---|---|
$uri $uri/ =404 | false | 404: out/teas/mint is a folder with no index.html. | /teas/mint.html works, but the links say /teas/mint. |
$uri $uri.html $uri/ =404 | false | 200, with out/teas/mint.html. | The try_files line of the docs' nginx example. Every link works. |
$uri $uri/ =404 | true | 200 for /teas/mint/, with out/teas/mint/index.html. | Links end in /, and every page is a folder's index.html. |
trailingSlash: true changes both sides. The build writes teas/mint/index.html instead of teas/mint.html, and every Link points at /teas/mint/. Most static hosts send a folder's index.html when the address ends in /, so this works on many hosts whose rules you can't change: check your host's docs. Without it, pick a host that tries .html, or add that rule, as the nginx example does. Either way, a link in the shop and the file in out must meet.
One more reason the rule matters: the router's prefetch checks each page with a HEAD request first. On the exact-names host, HEAD /teas would get 404 too, and Next.js's code drops a prefetch whose check fails (segment-cache/cache.ts, lines 1887 to 1898).
Single-page apps with Next.js
A single-page app, or SPA, is one HTML file. The browser loads it once; after that, JavaScript draws every screen and changes the address bar itself. Apps made with Create React App or a plain Vite setup often work this way. The Next.js docs' guide to SPAs, current for 16, makes two points:
- Next.js already gives you what people want from an SPA, without the one big file. The export writes an HTML file per route, so each page arrives with its content, and links change pages in place: the recorded click on Mint tea made no request.
- When you move an existing SPA to Next.js, you can keep it as one page at first, and adopt Next.js's routes later.
The second way looks like this. The tea app below was moved over with its own small router:
app/[[...slug]]/page.tsximport { TeaAppLoader } from "./tea-app-loader"; export function generateStaticParams() { return [{ slug: [""] }];} export default function Page() { return <TeaAppLoader />;}$ next build▲ Next.js 16.4.3 (Turbopack) Creating an optimized production build ...✓ Compiled successfully Running TypeScript ... Finished TypeScript ... Collecting page data ...✓ Generating static pages (3/3) Finalizing page optimization ... Route (app)┌ ○ /_not-found└ /[[...slug]] └ ● / ○ (Static) prerendered as static content● (SSG) prerendered as static HTML (uses generateStaticParams) # What out holds: 22 files, 617.0 kB (deeper folders added up)out/404.html 7.0 kBout/__next.$oc$slug.__PAGE__.txt 2.9 kBout/__next._full.txt 3.4 kBout/__next._tree.txt 186 bytesout/_next/static/ 575.2 kB, 11 filesout/_not-found.html 7.0 kBout/_not-found.txt 4.3 kBout/_not-found/__next._full.txt 4.3 kBout/_not-found/__next._not-found.__PAGE__.txt 3.8 kBout/_not-found/__next._tree.txt 164 bytesout/index.html 5.3 kBout/index.txt 3.4 kB # out, served by the recorder's file server, simulating: try_files $uri $uri.html $uri/ /index.htmlGET / 200 out/index.htmlGET /teas/mint 200 out/index.htmlGET /teas/mint 200 out/index.htmlGET /favicon.ico 200 out/index.htmlGET /favicon.ico 200 out/index.htmlGET /favicon.ico 200 out/index.htmlapp/[[...slug]]/page.tsx is an optional catch-all route: it matches / and every address below it (Dynamic routes). Line 4 lists only the empty slug, so the build writes one page, out/index.html, as the Terminal shows. The page shows the app through a loader:
"use client"; import dynamic from "next/dynamic"; const TeaApp = dynamic(() => import("../../tea-app"), { ssr: false, loading: () => <p>Loading the tea app…</p>,}); export function TeaAppLoader() { return <TeaApp />;} Line 5 loads tea-app.tsx with next/dynamic. Line 6, ssr: false, says: don't render it during the build, only in the browser. That matters here, because the app reads window.location, which doesn't exist during the build. Line 7 is what the HTML file holds instead, so out/index.html says Loading the tea app…, for every address.
"use client"; import { useState } from "react"; const teas = [ { slug: "mint", name: "Mint tea" }, { slug: "green", name: "Green tea" },]; export default function TeaApp() { const [path, setPath] = useState(window.location.pathname); function go(to: string) { window.history.pushState(null, "", to); setPath(to); } const tea = teas.find((item) => path === `/teas/${item.slug}`); if (tea) { return ( <main> <h1>{tea.name}</h1> <button onClick={() => go("/")}>All teas</button> </main> ); } return ( <main> <h1>Our teas</h1> {teas.map((item) => ( <button key={item.slug} onClick={() => go(`/teas/${item.slug}`)}> {item.name} </button> ))} </main> );} Line 11 starts from the address the browser is on. Line 13 changes the address bar without loading a page, with pushState, and line 14 shows the new screen. Line 16 picks the tea that matches the address. The docs say Next.js's router notices pushState calls like this one, so usePathname stays correct.
index.html for every address it can't find, and the app's code reads the address in the browser and picks what to show.The host is the other half. A visitor who opens /teas/mint directly asks for a file that doesn't exist. So this host's rule ends with /index.html instead of =404: send index.html for anything it can't find. In the recording, GET /teas/mint got out/index.html with 200, and in Steps, Chrome showed Mint tea. Clicking All teas, then Green tea, made no requests at all. The log also shows the cost: /favicon.ico got index.html too, with 200. A wrong address never gets a real 404.
One more trap for SPAs: reading the address with useSearchParams in an export. During the build, there are no search params, so Next.js needs a Suspense boundary around the component, to know what to put in the HTML instead (Search params). Without one, the build stops:
app/search/sorted-by.tsx"use client"; import { useSearchParams } from "next/navigation"; export default function SortedBy() { const sort = useSearchParams().get("sort") ?? "name"; return <p>Sorted by {sort}.</p>;}$ next build▲ Next.js 16.4.3 (Turbopack) Creating an optimized production build ...✓ Compiled successfully Running TypeScript ... Finished TypeScript ... Collecting page data ...⨯ useSearchParams() should be wrapped in a suspense boundary at page "/search". Read more: https://nextjs.org/docs/messages/missing-suspense-with-csr-bailout at nb (/my-app/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.prod.js:2:69690) at Object.use (/my-app/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.prod.js:2:74159) at t.use (/my-app/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.prod.js:2:191340) at Object.o (/my-app/.next/server/chunks/ssr/_1zr6cno-in201._.js:1:16312) at q (/my-app/.next/server/chunks/ssr/_1zr6cno-in201._.js:1:5217) at /my-app/.next/server/chunks/ssr/_1zr6cno-in201._.js:1:113 at al (/my-app/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.prod.js:2:86324) at ac (/my-app/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.prod.js:2:88140) at ac (/my-app/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.prod.js:2:106660) at ah (/my-app/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.prod.js:2:110003) at af (/my-app/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.prod.js:2:107320) at aS (/my-app/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.prod.js:2:115065) at ap (/my-app/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.prod.js:2:112064) at ah (/my-app/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.prod.js:2:110277) at af (/my-app/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.prod.js:2:107320) at aS (/my-app/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.prod.js:2:115065) at ac (/my-app/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.prod.js:2:100759) at ah (/my-app/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.prod.js:2:110003) at af (/my-app/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.prod.js:2:107320) at aS (/my-app/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.prod.js:2:115065) at ap (/my-app/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.prod.js:2:112064) at ah (/my-app/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.prod.js:2:110277) at af (/my-app/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.prod.js:2:107320) at ac (/my-app/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.prod.js:2:101342) at ah (/my-app/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.prod.js:2:110003) at af (/my-app/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.prod.js:2:107320) at ac (/my-app/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.prod.js:2:106278) at ah (/my-app/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.prod.js:2:110003) at af (/my-app/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.prod.js:2:107320) at au (/my-app/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.prod.js:2:86676) at ac (/my-app/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.prod.js:2:88185) at ah (/my-app/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.prod.js:2:110003) at af (/my-app/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.prod.js:2:107320) at aS (/my-app/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.prod.js:2:115065) at ap (/my-app/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.prod.js:2:112064) at ah (/my-app/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.prod.js:2:110277) at af (/my-app/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.prod.js:2:107320) at ac (/my-app/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.prod.js:2:88104) at ah (/my-app/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.prod.js:2:110003) at af (/my-app/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.prod.js:2:107320) at au (/my-app/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.prod.js:2:86676) at ac (/my-app/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.prod.js:2:88185) at ah (/my-app/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.prod.js:2:110003) at af (/my-app/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.prod.js:2:107320) at ac (/my-app/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.prod.js:2:101342) at ah (/my-app/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.prod.js:2:110003) at af (/my-app/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.prod.js:2:107320) at au (/my-app/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.prod.js:2:86676) at ac (/my-app/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.prod.js:2:88185) at ah (/my-app/node_modules/next/dist/compiled/next-server/app-page-turbo.runtime.prod.js:2:110003)Error occurred prerendering page "/search". Read more: https://nextjs.org/docs/messages/prerender-errorExport encountered an error on /search/page: /search, exiting the build.⨯ Next.js build worker exited with code: 1 and signal: nullThe first error line names the problem: useSearchParams() should be wrapped in a suspense boundary at page "/search". The lines after it are the stack trace, with the build's own file names. Wrap <SortedBy /> in <Suspense> with a fallback, and the build writes the fallback into the HTML; the browser fills in the real text (Loading UI).
Is the tea shop ready to export?
Before you set output: "export" on a real project, sort what it uses. Each card is something the tea shop might do, and a recording above decides each one.
- The home page greets visitors in the language
headers()reports. - A newsletter form whose action is a Server Action.
app/teas/[slug]/page.tsxwith nogenerateStaticParams.app/teas.json/route.tswithoutexport const dynamic = "force-static".proxy.tsrewrites/menuto the teas page.redirects()innext.config.tssends/menuto/teas.- The teas page sets
export const revalidate = 3600. next/imagewith the default loader.- Tea pages whose slugs
generateStaticParamslists. - A Client Component that fetches
/teas.jsonin the browser. - A title template in the root layout's
metadata. images: { unoptimized: true }innext.config.ts.
Sort each feature: does it stop the export's build, build but never run on a static host, or work in the export?
If everything lands in the third group, export. If some of it is in the first two, you have two choices: move that part to the browser or to another service, or run a server (Self-hosting). A shop whose cart, sign-in and checkout live on a service like Shopify or Stripe can still be an export: its own pages are the same for everyone.
Common mix-ups
- "If the export builds, everything in the code works." No.
proxy.ts,redirects(),revalidateand the default image loader all built, and none of them worked on the host. - "A static export is a single-page app." No. It writes an HTML file per route, and the router moves between them in place. A one-file SPA is something you set up on purpose, with a catch-all route and a host rule.
- "An export can't use data." It can, at two times: during the build, in Server Components, and in the browser, in Client Components. Not on a server per request.
- "Every static host serves /teas/mint." No. A host that only tries exact names answered 404. Use
trailingSlash: true, or a host rule that tries.html. - "An export can keep 'use cache'." Not in 16.4.3. The export needs Cache Components off, and
"use cache"stops the build without it. - "Every click in an export loads a new page." No. In the recording, clicking Mint tea made no request: the router had prefetched it from
out.
Practice exercises
These exercises are about Next.js projects, which can't run inside this page. Answer first, then use the hints.
The shop is exported without trailingSlash and served with this nginx config. Which file does it send for /teas/green?
server {
listen 80;
server_name teashop.example;
root /var/www/tea-shop/out;
location / {
try_files $uri $uri.html $uri/ =404;
}
# This is necessary when `trailingSlash: false`.
# You can omit this when `trailingSlash: true`.
location /teas/ {
rewrite ^/teas/(.*)$ /teas/$1.html break;
}
error_page 404 /404.html;
location = /404.html {
internal;
}
}
out/teas/green.html. /teas/green matches both location / and location /teas/, and nginx picks the longer prefix: line 14 rewrites it to /teas/green.html, a file the recorded out folder lists. The try_files line on line 8 would find the same file with its $uri.html try.
This is app/teas/[slug]/page.tsx in an export. The build stops. Which function does its error ask for?
export default async function TeaPage({ params }: PageProps<"/teas/[slug]">) {
const { slug } = await params;
return (
<main>
<h1>{slug}</h1>
</main>
);
}
export async function generateStaticParams() {
const teas = await getTeas();
return teas.map((tea) => ({ slug: tea.slug }));
}generateStaticParams. The recorded build stopped with: Page "/teas/[slug]" is missing "generateStaticParams()" so it cannot be used with "output: export" config. Each slug it returns becomes a file.
This JSON route stops the export. Which one line makes it build, and become out/teas.json?
import { getTeas } from "@/lib/teas";
export async function GET() {
return Response.json(await getTeas());
}
import { getTeas } from "@/lib/teas";
export const dynamic = "force-static";
export async function GET() {
return Response.json(await getTeas());
}
Add export const dynamic = "force-static";. It tells Next.js to run the handler during the build and keep its answer, which the export writes as out/teas.json. Without it, the recorded build stopped.
The exported shop has this proxy.ts and is served as plain files. A visitor opens /menu. What status does the host answer with?
import { NextResponse, type NextRequest } from "next/server";
// The old menu address shows the teas page.
export function proxy(request: NextRequest) {
return NextResponse.rewrite(new URL("/teas", request.url));
}
export const config = { matcher: "/menu" };
404. The build passed with a warning that the export disables middleware, so nothing runs the rewrite on line 5. The host looks for a menu file, finds none, and sends 404.html, as the recording shows.
The exported home page shows this photo. The host answers 404 for it. What do you add to next.config.ts so a static host can show it?
import Image from "next/image";
export default function Home() {
return (
<main>
<h1>Tea shop</h1>
<Image src="/mint-tea.jpg" alt="A cup of mint tea" width={480} height={320} />
</main>
);
}
images: { unoptimized: true },
// or
images: { loader: "custom", loaderFile: "./image-loader.ts" },Add images: { unoptimized: true } to next.config.ts, so the tag points at /mint-tea.jpg itself, or a custom loader for an image service. The default loader needs Next.js's image optimizer, which a static host doesn't have.
The shop's new host can't be configured. It sends a file with the exact name, or a folder's index.html, and nothing else. /teas/mint is a 404 there. Which Next.js option fixes it?
const nextConfig: NextConfig = {
output: "export",
trailingSlash: true,
};trailingSlash: true. The build then writes teas/mint/index.html and links to /teas/mint/, which a host that sends a folder's index.html finds. The recorded host answered 200.
Check your understanding
For each question, ask what was decided during the build, and what the host can do with only files.
Question 1 of 7Why does the tea shop's static export turn Cache Components off in Next.js 16.4.3?
Choose an answer to see the explanation.
Question 2 of 7The teas page sets
export const revalidate = 3600in an export. What happens an hour after the site goes live?Choose an answer to see the explanation.
Question 3 of 7Which of these stopped the tea shop's export build?
Choose an answer to see the explanation.
Question 4 of 7On the exported shop, Chrome is on
/and the visitor clicks Teas. What had the browser already asked the host for?Choose an answer to see the explanation.
Question 5 of 7A host follows only the rule
try_files $uri $uri.html $uri/ =404. Which file does it send for/teas/mint?Choose an answer to see the explanation.
Question 6 of 7The shop exports a page with
next/imageand the default loader. What does the visitor's browser get for the photo?Choose an answer to see the explanation.
Question 7 of 7A single-page app exports one
index.html. What must its host do for/teas/mint?Choose an answer to see the explanation.
Key takeaways
output: "export"makesnext buildwrite the site intoout: an HTML file per page,.txtfiles for the router, Route Handlers' answers, and_next/static.- In Next.js 16.4.3, an export needs Cache Components off. That means the older model: no
"use cache", andexport const dynamic = "force-static"on Route Handlers. - Server Components, listed dynamic routes, metadata and GET Route Handlers run during the build. Client Components, fetches and the router run in the browser.
- Request APIs, Server Actions, unlisted params and Route Handlers without
force-staticstop the build.proxy.ts, redirects,revalidateand the default image loader build, then never run. - Use
unoptimizedor a custom loader for images, and matchtrailingSlashto your host's rules. A single-page app needs a host that sendsindex.htmlfor every address.
Remember the one-liner.
An export keeps what the build made, and the browser does the rest; anything that needs a request needs a server.
These links point at release v16.4.3, the version every example on this page was built with. The export's checks live in export/index.ts: the warning about rewrites, redirects and headers on lines 248 to 258, and the Cache Components invariant on lines 903 to 907. The router adds .txt to an address in fetch-server-response.ts, and turns a prefetch into a __next….txt file name in segment-cache/cache.ts.
Coming next: Adapters and other platforms. It looks at the public Build Adapters API, and compares how Vercel, Netlify, Cloudflare and AWS run Next.js: which of them use that API, and which features each supports.