[]previous and next page

Static export

Static export in the Deploy guide.

For agentsUpdated

A site without a server needs the pages as files. There are two ways to get them: let dusk build prerender, or call prerender yourself.

Prerender at build time#

A route module exports prerender = true (a static path), prerender = ["/posts/1", "/posts/2"] (a list for a path with params), or ssr = false (its shell, see Dusk: routing). The build then requests each path from the built handler and writes dist/client/<path>/index.html. The shape of prerender in dusk():

vite.config.tsts
import { dusk } from "@shadow-js/dusk/vite";
import { defineConfig } from "vite";

export default defineConfig({
  plugins: [
    dusk({
      prerender: {
        routes: ["/extra"],
        origin: "https://example.com",
        concurrency: 4,
        failOnError: true,
      },
    }),
  ],
});

routes adds paths that no route exports, origin is what the handler is told it is served from (default http://localhost), concurrency is 1 to 64, and failOnError: false skips a path that does not answer 200 instead of failing the build. prerender: false turns the step off. The request goes through the real handler, including the host check. Without origin the build renders from http://localhost when allowedHosts allows it (or is not set), and otherwise from the first host of allowedHosts that is not a wildcard, so allowedHosts: ["example.com"] needs no origin. A list of wildcard patterns only (["*.example.com"]) leaves nothing to choose: the build stops and names security.allowedHosts. An origin that the host check refuses stops the build with a message that names the option and the host it used:

text
PrerenderError: [PRERENDER] "/about" answered 400: the host "staging.test" is not in security.allowedHosts (["example.com"]); set the prerender origin to an allowed host.

prerender()#

prerender from @shadow-js/dusk/static does the same for any handler, into any folder, and writes two more files: the SPA shell and a not-found page. The output directory contains the prerendered pages, the SPA shell and 404.html.

PrerenderOptions Default Meaning
handler, outDir, paths required the handler, the folder, and the paths (or a function that returns them)
origin http://localhost origin the handler is told it is served from
format "directory" /about becomes about/index.html; "file" writes about.html
concurrency 4 pages rendered at once
maxPages, maxPageBytes, pageTimeoutMs 10000, 16 MiB, 30000 bounds; more pages than maxPages, or a page larger than maxPageBytes, is an error
allowStatus [200] statuses that are written; any other fails the run
onRedirect "error" "html" writes a meta-refresh page for a same-origin redirect
shell "200.html" file for the SPA shell (handler.renderShell(), the document without route content); false skips it
notFoundPath "/__shadow_not_found__" path rendered into 404.html; false skips it
headers none headers added to every request

A failure throws PrerenderError with a code: PRERENDER_PATH_REJECTED (a path with .., control characters or an over-long segment), PRERENDER_PATH_CONFLICT (two paths, one file), PRERENDER_MAX_PAGES, PRERENDER_NON_OK, PRERENDER_REDIRECT and PRERENDER_PAGE_TOO_LARGE. Every path is validated before anything is rendered.

Serving the files#

Any static host works. The Node server can serve the folder too: index: "index.html" maps /about to about/index.html, and a handler that answers everything else with 200.html is the single-page-app fallback for paths that were not prerendered, such as /dashboard (ssr = false pages boot from the shell on the client).

For a static host, rewrite unknown paths to the SPA shell. Existing prerendered pages and assets must take priority. The nginx configuration below uses that lookup order; confirm the equivalent rule with your host.

nginx
root /var/www/site;
location / {
  try_files $uri $uri/index.html /200.html;
}
error_page 404 /404.html;

Hosts that read a _redirects file (Netlify, Cloudflare Pages) take a rewrite, which keeps the status 200:

text
/*  /200.html  200

Which prerendered pages the handler answers#

A prerendered page is either its file or the handler's answer, and the build decides which before it writes the manifest. A page is a file only when the build can see that nothing that runs at request time changes its answer. Otherwise the handler answers it per request: the middleware runs first, and the page is rendered, so it gets the headers and the nonce a dynamic page gets. A file cannot see the request (its host, its cookies, its nonce), which is why the rule is this strict.

The page is When What answers a request
static (file) No route or _middleware module runs for it. No redirect, rewrite, route redirect or header rule with a condition (host, cookie, header or query) matches its path. The entry runs no global middleware, and its security.headers, security.csp, routing and trustedOrigins are literal values the build can read (or it sets none of them) the file, with the app's security headers
static (via handler) Any of the rules above applies to its path, or the entry sets a value the build cannot read the handler, per request (the middleware first, then the page)

The build reads these options from src/entry-server.ts the same way the handler merges them over dusk.config.ts (the entry wins): middleware, security.headers, security.csp, routing and trustedOrigins. A global middleware makes every prerendered page static (via handler), because the middleware can answer any request. A value the build cannot read (a call such as middleware: makeChain(), an imported array, a computed key, a spread, a security.headers built by a function, or an object the entry changes after it declares it: options.middleware = [...]) does the same, so the build never guesses. The other entry options do not change a file's answer and are not read here: a secret or a limit. security.allowedHosts is read only for the platform host routes, which are written for a literal list (see Platform host lists); it never makes a page a handler page.

To check a build, run dusk build: each route's kind is printed as static (file) or static (via handler). The manifest (dist/dusk-manifest.json) lists every prerendered page under staticPages, with its mode and, for a handler page, the reason that names the rule. The adapters follow the same decision: the Node static layer and dusk start leave a handler page to the handler, Vercel routes it to the function before the filesystem, Netlify forces it to the function in _redirects, Cloudflare lists it in run_worker_first, and Bun and Deno answer it with the handler. A refused Host goes to the handler on every layer that has a host list (see Platform host lists).

There is no option that forces a file for a page the middleware can reach, on purpose. A page the middleware does not reach stays a file, so the cost of a handler page is one render per request, as for any dynamic page. Narrowing the middleware (a _middleware folder for the sites that need it, instead of the entry's global list) is what makes more pages files.