Static export
Static export in the Deploy guide.
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():
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:
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.
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:
/* /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.