Documentation
Static export
Prerender the whole site to files any host can serve, and know exactly which pages cannot go.
stoneware export renders every page once, at build time, and writes the result as plain files. No Bun runs in production, no server process exists, and hosting costs whatever a bucket costs. It is the right choice for a site whose pages are the same for every visitor.
$ stoneware export
[stoneware] exported 12 page(s) in 394ms
output /home/you/site/dist
skipped /api/subscribe (server action)
skipped /contact (renders a CSRF token)
skipped /blog/[slug] (no staticPaths export)
csp embedded in every page, and written to _headersRead the skipped lines every time. They are not warnings about the export — they are the list of pages that will not exist on the deployed site, each with the reason it could not be prerendered.
build or export
stoneware build stoneware export
────────────────────────── ──────────────────────────
a server bundle you run a directory of files
.stoneware/server.js dist/
needs Bun in production needs nothing in production
any static host will do
forms, sessions, anything no request ever reaches
per-visitor, works your code, so none of it runs
CSP as a real header CSP as a meta tag, plus a
_headers fileThe dividing line is whether a page differs per visitor. A blog, a docs site or a marketing site is identical for everyone and exports cleanly. A page with a login, a cart or a <Form> needs a server, because the thing that makes it work happens when the request arrives.
Both commands run the same rendering pipeline. An exported page is byte-for-byte what the server would have sent, with one deliberate exception: the Content-Security-Policy is embedded as a meta tag, because static files carry no response headers.
What gets written
dist/
index.html /
about/index.html /about
blog/hello/index.html /blog/hello
404.html any unmatched path
_headers CSP, for hosts that read it
_stoneware/
styles-4kq2n7wd.css the bundled stylesheet
Counter-6eq2vxv9.js one chunk per island
chunk-zr89pq4f.js shared runtime
favicon.ico everything from public/,
img/hero.jpg copied to the rootPages are written as <path>/index.html rather than <path>.html, so a static host serves them at the same URLs the dev server used, with no trailing-slash redirect to configure. A non-HTML route — a sitemap.xml.ts or robots.txt.ts — is written at its literal path instead, because a crawler looking for /sitemap.xml will not find /sitemap.xml/index.html.
Everything under public/ is copied to the root of dist/ as-is, and the hashed island chunks and stylesheet land under _stoneware/ at the same URLs the pages reference. Islands hydrate on an exported site exactly as they do on a served one — export removes the server, not the interactivity.
Dynamic routes need staticPaths()
A route like blog/[slug].tsx matches infinitely many URLs, and prerendering means writing a finite number of files. Nothing can guess the list, so the route exports it.
import { notFound } from "stoneware";
import { allPosts, getPost } from "../../lib/posts.ts";
/** One object per page to write. Keys are the route's params. */
export function staticPaths() {
return allPosts().map((post) => ({ slug: post.slug }));
}
export default function Post({ params }: PageProps) {
const post = getPost(params.slug);
if (!post) notFound();
return <article><h1>{post.title}</h1></article>;
} staticPaths() returns export writes
────────────────────────── ──────────────────────────
{ slug: "hello" } dist/blog/hello/index.html
{ slug: "on-bun" } dist/blog/on-bun/index.html
no staticPaths export nothing, and the route is
listed as skippedIt may be async, so reading a directory of markdown or querying a database is fine — it runs at build time, on your machine, where the database is reachable. A nested route with two params returns both keys per object.
A dynamic route with no staticPaths is skipped rather than failing the export, because a project can legitimately serve some routes and export others. That is why the skipped list is worth reading: a missing staticPaths and a deliberate omission look identical from the outside.
Pages that cannot be exported
renders a CSRF token a <Form> or csrfToken() on the page.
The token is per-visitor and expires;
baking one into a file would ship a
single token to everyone, then expire.
server action routes/api/* has no HTML to write and
nothing to answer a POST with.
no staticPaths export a [slug] route with no list of pages
to write.The CSRF rule is the one that surprises people, and it is a safety property rather than a limitation. A prerendered page is one file served to everybody, so a token embedded in it would be shared by every visitor and dead as soon as it expired. Rather than write a page whose form silently fails, the export leaves it out and says so.
If you need a form on an exported site, point it at something that is not Stoneware — a form service, a function on the host, an API on another origin — and drop the <Form> helper for a plain <form>. If you need several such pages, that is the signal to deploy the server build instead.
The export checks its own links
After the pages are written, the export resolves every same-origin href and src in them against the directory it is about to hand you. Anything that resolves to nothing is named, because a link to a page that was never written is a 404 waiting on a site that otherwise looks finished.
[stoneware] exported 10 page(s) in 394ms
skipped /divisions/[division] (no staticPaths export)
[stoneware] 7 link(s) point at pages this export did not write:
/divisions -> /divisions/agro-fresh-produce
/divisions -> /divisions/spices-seasonings
...
Each of these will 404 on the deployed site. A dynamic route needs a
staticPaths() export before it can be prerendered.The skipped line was always printed and is easy to read past — one line among several, informational in tone, on a command that exits 0. The second block is the consequence of it, stated as what a visitor will experience rather than as a fact about the build.
reported ignored
────────────────────────── ──────────────────────────
a page never written a page that was written
a typo in an href an external origin
a missing image or asset ?query and #fragment
a bare #fragment
the framework's own chunkssrc as well as href: a stylesheet or island chunk that is not there presents as "the CSS is broken" rather than as a missing file, and that is the harder of the two to diagnose from the outside.
Failing the build instead: --strict
stoneware export --strictExits 1 if any route was skipped or any link dangles. Without it the export still exits 0 and still prints everything above — because a project may legitimately prerender some routes and serve others, and failing that build would be wrong. --strict is how you say this site is meant to be complete.
The pages that could be written still are, with or without the flag. The report is a warning about the output, not a refusal to produce it — a site with one broken section is still worth having while you fix the section.
Deploying the directory
bun run build # if you want to check it compiles
stoneware export # writes dist/
stoneware preview # serve dist/ the way a static host will
# then hand dist/ to any of these
netlify deploy --prod --dir dist
wrangler pages deploy dist
aws s3 sync dist s3://your-bucket --deletestoneware preview is worth the extra ten seconds. It serves dist/ with a static host's conventions rather than the dev server's — <path>/index.html for a page, 404.html for a miss, and no response headers at all — which is the one way to see the export as a visitor will before it is live.
Netlify reads _headers. Nothing to configure.
Cloudflare Pages reads _headers. Nothing to configure.
GitHub Pages serves 404.html. No header support -
the meta-tag CSP is what you get.
S3 + CloudFront set the error document to 404.html;
add headers in the distribution.The generated _headers file carries the full Content-Security-Policy for the hosts that read one. Everywhere else the policy still applies through the meta tag every page carries, minus three directives — frame-ancestors, report-uri and sandbox — which browsers ignore in a meta tag and which the export names explicitly rather than pretending to enforce.
404.html is the filename every static host looks for, so your routes/_404.tsx is what visitors see on a bad URL instead of the host's default page. Nothing needs to be configured for it beyond the file existing.
Something wrong in the framework itself rather than the page? Open an issue on GitHub.