cf.completefrontend
Open lab My progress
THE NEXT.JS FIELD GUIDE

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.

By the end, you can
  • 01
    Export an app as filesSet output: 'export', read the out folder, and say why Next.js 16.4.3 needs Cache Components off for it.
  • 02
    Tell what needs a serverKnow which features stop the build, which build but never run on a static host, and what to use instead.
  • 03
    Serve 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.

The tea shop, exported and served as plain filesNext.js
app/search/teas/[slug]/teas.json/lib/
next.config.ts
import type { NextConfig } from "next"; const nextConfig: NextConfig = {  output: "export",}; export default nextConfig;
Recorded from Next.js 16.4.3
$ 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.json

Open the Terminal tab. It shows three things, in order:

  1. What next build printed. Every route is ○, "prerendered as static content", or ●, "prerendered as static HTML (uses generateStaticParams)": the three tea pages.
  2. What the out folder held afterwards: 49 files, like index.html, teas/mint.html and teas.json.
  3. The host's rule and its log: each address it was asked for, the status it answered, and the file it sent. GET /teas/mint got out/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 writes a folder called out with index.html, teas.html, teas/mint.html, teas.json and the _next/static folder. A static host takes files from that folder and sends them to the visitor's browser. The host runs no code of yours.next buildoutindex.htmlteas.htmlteas/mint.htmlteas.json_next/static/written once, during the builda static hostfinds a file, sends itthe browser
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.
Real-life analogyA printed menu booklet

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 build writes the out folder
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
The main idea

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:

next.config.ts: a static exportTypeScript
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:

What the out folder holds
FilesWhat they areWho asks for them
index.html, teas.html, teas/mint.htmlOne HTML page for each route the build made.A browser, on a direct visit or a reload.
404.htmlThe not-found page, made from the app's not-found (here Next.js's own).The host, when no file matches the address.
teas.jsonThe JSON Route Handler's answer, written as a file.The search box, with fetch in the browser.
index.txt, teas.txt, teas/mint.txtEach 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__.txtThe 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.

Playground: can an export keep Cache Components?
The tea shop as an export, with this version of its filesNext.js
app/search/teas/[slug]/teas.json/lib/
next.config.ts
import type { NextConfig } from "next"; const nextConfig: NextConfig = {  cacheComponents: true,  partialPrefetching: true,  output: "export",}; export default nextConfig;
next build failed · Next.js 16.4.3
$ 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 frames
Try it yourself
On, as in new projects

Lines 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.

Every version was built with next build from Next.js 16.4.3; the Terminal shows where it stopped. Reset goes back to the first version.

What each build printed:

  1. 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's dynamic line belongs to the older model.
  2. 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.
  3. 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.
  4. Off, with one "use cache": the build stops on line 4 of app/page.tsx. "use cache" needs the cacheComponents flag.

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", cacheLife or cacheTag. 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.ts asks 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 during next 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.tsx returns 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-grey got out/404.html, with status 404.
  • Metadata. The root layout's title template on line 5 of app/layout.tsx, and each tea's generateMetadata, ran during the build. The recorded /teas/mint came with the title Mint 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.
app/teas.json/route.ts: JSON written as a fileTypeScript
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).

Three columns. During next build: Server Components, generateStaticParams, the GET handler for teas.json, and metadata. On the static host: it finds teas.html and sends the file; running your code is crossed out. In the browser: Client Components, fetch of teas.json, and the prefetches of links.During next buildServer ComponentsgenerateStaticParamsGET /teas.jsonmetadataOn the static hostfinds teas.htmlsends the fileIn the browserClient Componentsfetch("/teas.json")Link prefetchesruns your code
In an export, your server code runs once, during 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.

Chrome clicking through the exported shopNext.js
app/search/teas/[slug]/teas.json/lib/
next.config.ts
import type { NextConfig } from "next"; const nextConfig: NextConfig = {  output: "export",}; export default nextConfig;
Recorded from Next.js 16.4.3

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.

localhost:3000/teasTeas | Tea shop
Home Teas Search
Teas
  • 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

  1. HEAD/teas/mint200text/html
  2. HEAD/teas/green200text/html
  3. HEAD/teas/oolong200text/html
  4. GET/teas/mint/__next._tree.txt200text/plain
    rsc
    1
    next-router-prefetch
    1
    next-router-segment-prefetch
    /_tree
    next-url
    /teas
  5. GET/teas/green/__next._tree.txt200text/plain
    rsc
    1
    next-router-prefetch
    1
    next-router-segment-prefetch
    /_tree
    next-url
    /teas
  6. GET/teas/oolong/__next._tree.txt200text/plain
    rsc
    1
    next-router-prefetch
    1
    next-router-segment-prefetch
    /_tree
    next-url
    /teas
  7. 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
  8. 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
  9. 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:

  1. Open /: one page load, GET /. Then the router prefetches the three links on the page. For each, it sends HEAD /teas first, which checks the page exists, then asks for files like /teas/__next._tree.txt and /teas/__next.teas.__PAGE__.txt.
  2. 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.
  3. Click Mint tea: no requests at all. The page changed in place.
  4. 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:

app/search/tea-search.tsx: fetching in the browserTSX
"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.

Playground: what stops an export?
The exported tea shop, with one file changedNext.js
app/search/teas/[slug]/teas.json/lib/
app/page.tsx
import { 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 failed · Next.js 16.4.3
$ 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: null
Try it yourself
headers()

Line 4 reads the request's accept-language header. During the build there's no request, so the build stops on /.

Each version is the exported shop with one file changed, built with next build from Next.js 16.4.3. The Terminal shows the error that stopped it. Reset goes back to the first version.

The six errors, word for word, and what to do instead:

  1. 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.language instead of the language header, and document.cookie only for preferences that aren't secret, like a chosen theme. A sign-in cookie should be HttpOnly, 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.
  2. 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.
  3. No generateStaticParams: Page "/teas/[slug]" is missing "generateStaticParams()" so it cannot be used with "output: export" config. List every tea the build should write.
  4. 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.
  5. 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.
  6. sitemap.ts: the same error, for /sitemap.xml. A sitemap.ts or robots.ts is a Route Handler too, so it needs export 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:

Playground: what builds, but never runs?
The exported tea shop, with one feature that needs a serverNext.js
app/search/teas/[slug]/teas.json/lib/
proxy.ts
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" };
Recorded from Next.js 16.4.3

GET /menu404

Response headers
Content-Type text/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,&quot;Segoe UI&quot;,Roboto,Helvetica,Arial,sans-serif,&quot;Apple Color Emoji&quot;,&quot;Segoe UI Emoji&quot;;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>
Try it yourself
proxy.ts

The build passes with a warning that the export disables middleware. The host answers /menu with 404: nothing runs the proxy.

Each version built with Next.js 16.4.3, and its out folder was served as plain files by the recorder's file server, which simulates the try_files rule from the docs' nginx example. The Network tab shows what the host answered; the Terminal shows the build's warnings. Reset goes back to the first version.
  • 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, /menu was 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). /menu was a 404. The export's code gives the same warning for rewrites() and headers() (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 1h next 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:

What each server feature did in the recorded export
FeatureWhat next build didWhat a visitor gets
headers() or cookies() in a pageStops: the route couldn't be rendered statically.Nothing: there's no site to visit.
A Server ActionStops: Server Actions are not supported with static export.Nothing: there's no site to visit.
A dynamic route without generateStaticParamsStops: the page is missing generateStaticParams().Nothing: there's no site to visit.
dynamicParams = trueStops: it cannot be used with output: export.Nothing: there's no site to visit.
A GET Route Handler (or sitemap.ts) without force-staticStops: dynamic = "force-static" is not configured.Nothing: there's no site to visit.
proxy.tsBuilds, with a warning that the export disables middleware.A 404 for /menu: nothing runs the proxy.
redirects() in next.config.tsBuilds, with two warnings that redirects aren't applied.A 404 for /menu: nothing sends the redirect.
export const revalidate = 3600Builds, with no warning. The table still says 1h.The build's file, until the next build.
next/image with the default loaderBuilds, 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:

app/page.tsx: a photo with next/imageTSX
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:

Playground: which photo reaches the visitor?
The exported home page with a photo, and this image settingNext.js
app/search/teas/[slug]/teas.json/lib/public/
app/page.tsx
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>  );}
Recorded from Next.js 16.4.3

GET /200

Response headers
Content-Type text/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>
Try it yourself
Default loader

The img tag points at /_next/image, which needs a Next.js server. The second answer is the host's 404.

Each version was built with Next.js 16.4.3 and served as plain files. Open the Network tab's HTML to see the img tag the build wrote, and the next answer to see what the host sent for the photo. Reset goes back to the first version.
  • The default loader: the build passed. The tag's src is /_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), and output: "export" runs it: the build calls the export with buildExport: false (build/index.ts, lines 1079 to 1082). But the check fires only when the build has marked next/image as 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 from public, 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 into public.
  • 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.
image-loader.ts: addresses for an image serviceTypeScript
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/:

nginx: the docs' example, for the shop's out folderText
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:

  1. Line 5 points nginx at the out folder, and line 8 is the general rule: try the address as a file ($uri), then with .html added ($uri.html), then as a folder with an index.html ($uri/), and answer 404 if none is there.
  2. Lines 13 to 15 are for the tea pages. Line 14 rewrites /teas/mint to /teas/mint.html. The comment on lines 11 and 12 is the docs': the block is needed with trailingSlash: false, and can go with true. Read line 14's pattern before you copy it: (.*) also matches the router's prefetch files, like /teas/__next._tree.txt, and would add .html to them too. Narrow it to page addresses, and test it on your own server.
  3. Line 17 sends 404.html for any 404, and lines 18 to 20 mark that file internal: 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.

A request for slash teas slash mint. The host's first try is out/teas/mint, which is a folder, not a file, so it's skipped. The second try adds .html: out/teas/mint.html exists and is sent with status 200. The third try, the folder's index.html, is never needed.GET /teas/mint1. $uriout/teas/minta folder, not a file2. $uri.htmlout/teas/mint.htmlfound: 2003. $uri/out/teas/mint/index.htmlnever needed
With 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:

Playground: how does the host find /teas/mint?
The exported tea shop on a static host with this ruleNext.js
app/search/teas/[slug]/teas.json/lib/
next.config.ts
import type { NextConfig } from "next"; const nextConfig: NextConfig = {  output: "export",}; export default nextConfig;
Recorded from Next.js 16.4.3
$ 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.html
Try it yourself
Exact names only

The 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.

The Terminal ends with the simulated host's rule and its log: each address asked for, the status, and the file it sent. Every version was built with Next.js 16.4.3. Reset goes back to the first version.

Each row is one of the three hosts, and what it sent for the tea page:

One shop, three hosts
The host's ruletrailingSlashWhat /teas/mint getsNotes
$uri $uri/ =404false404: out/teas/mint is a folder with no index.html./teas/mint.html works, but the links say /teas/mint.
$uri $uri.html $uri/ =404false200, with out/teas/mint.html.The try_files line of the docs' nginx example. Every link works.
$uri $uri/ =404true200 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:

A single-page tea app, exported as one HTML fileNext.js
app/[[...slug]]/
app/[[...slug]]/page.tsx
import { TeaAppLoader } from "./tea-app-loader"; export function generateStaticParams() {  return [{ slug: [""] }];} export default function Page() {  return <TeaAppLoader />;}
Recorded from Next.js 16.4.3
$ 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.html

app/[[...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:

app/[[...slug]]/tea-app-loader.tsx: the app, only in the browserTSX
"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.

tea-app.tsx: the app's own small routerTSX
"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.

Three addresses, slash, slash teas slash mint and slash teas slash green, all lead to the same file, out/index.html, because the host's rule ends with slash index.html. In the browser, TeaApp reads the address and shows Mint tea.//teas/mint/teas/greenout/index.htmlthe same file for eachbrowserTeaAppMint teareads the address
A single-page app has one HTML file. The host sends 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:

useSearchParams with no Suspense boundary, in an exportNext.js
app/search/teas/[slug]/teas.json/lib/
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 failed · Next.js 16.4.3
$ 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: null

The 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.

Stops, quietly never runs, or works?
  • The home page greets visitors in the language headers() reports.
  • A newsletter form whose action is a Server Action.
  • app/teas/[slug]/page.tsx with no generateStaticParams.
  • app/teas.json/route.ts without export const dynamic = "force-static".
  • proxy.ts rewrites /menu to the teas page.
  • redirects() in next.config.ts sends /menu to /teas.
  • The teas page sets export const revalidate = 3600.
  • next/image with the default loader.
  • Tea pages whose slugs generateStaticParams lists.
  • A Client Component that fetches /teas.json in the browser.
  • A title template in the root layout's metadata.
  • images: { unoptimized: true } in next.config.ts.
Try it yourself
0 of 12 correct

Sort each feature: does it stop the export's build, build but never run on a static host, or work in the export?

Choose a category for every card. You can change an answer at any time; Reset clears them all.

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(), revalidate and 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.

Exercise 1 · Warm-upFind the green tea's file

The shop is exported without trailingSlash and served with this nginx config. Which file does it send for /teas/green?

Starter codeText
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;
  }
}

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upRead the build's error

    This is app/teas/[slug]/page.tsx in an export. The build stops. Which function does its error ask for?

    Starter codeTSX
    export default async function TeaPage({ params }: PageProps<"/teas/[slug]">) {
      const { slug } = await params;
      return (
        <main>
          <h1>{slug}</h1>
        </main>
      );
    }
    

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeExport the JSON route

      This JSON route stops the export. Which one line makes it build, and become out/teas.json?

      Starter codeTypeScript
      import { getTeas } from "@/lib/teas";
      
      export async function GET() {
        return Response.json(await getTeas());
      }
      

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeFollow the old menu address

        The exported shop has this proxy.ts and is served as plain files. A visitor opens /menu. What status does the host answer with?

        Starter codeTypeScript
        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" };
        

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeMake the photo appear

          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?

          Starter codeTSX
          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>
            );
          }
          

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeA host that only knows folders

            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?

            Answer, then press Check. Spacing and letter case don’t matter.

              Check your understanding

              For each question, ask what was decided during the build, and what the host can do with only files.

              The Static exports quiz · 7 questionsScore: first tries count
              1. 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.

              2. Question 2 of 7The teas page sets export const revalidate = 3600 in an export. What happens an hour after the site goes live?

                Choose an answer to see the explanation.

              3. Question 3 of 7Which of these stopped the tea shop's export build?

                Choose an answer to see the explanation.

              4. 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.

              5. 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.

              6. Question 6 of 7The shop exports a page with next/image and the default loader. What does the visitor's browser get for the photo?

                Choose an answer to see the explanation.

              7. 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" makes next build write the site into out: an HTML file per page, .txt files 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", and export 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-static stop the build. proxy.ts, redirects, revalidate and the default image loader build, then never run.
              • Use unoptimized or a custom loader for images, and match trailingSlash to your host's rules. A single-page app needs a host that sends index.html for 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.

              Read it in Next.js's own code

              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.

              CompleteFrontend Clear concepts. Working examples.