stoneware

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.

terminalsh
$ 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 _headers

Read 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 file
the same project, two outputs

The 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 root
dist/, after an export

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

routes/blog/[slug].tsxtsx
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 skipped
what that produces

It 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 three reasons a page is skipped

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.

what an incomplete export now saystxt
[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 chunks
what it reports, and what it deliberately ignores

src 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

in CIsh
stoneware export --strict

Exits 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

the whole workflowsh
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 --delete

stoneware 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.
hosts, and what each needs

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.