Quill docs
Static export & SSG (qquill-build)
A Quill app is a single Rust binary with two modes: it can serve server-rendered HTML over a worker, or render every page once at build time and write the result to disk as plain files. Because both modes read the same PAGES list and go through the same render path (app::respond_html), what you serve and what you export are byte-identical — there is no separate "SSG renderer" that could drift from the live server. This page explains the static-export model end to end: how quill build works, the URL-to-file mapping that produces pretty URLs, the byte-exactness guarantee, and the Cloudflare Pages control files (_headers, _redirects, 404.html) that Quill emits for you.
The SSG model: one render path, two modes#
Every Quill page is a Rust function pub fn respond(input: &[u8]) -> FunctionResponse that builds a complete HTML document — doctype, inlined head CSS, and (only on pages that use islands) the props sidecars plus the one runtime <script> — and frames it as an HTTP response. A page handler already produces the *complete* document. The only thing standing between that handler and a plain .html file on disk is the __headers response frame the handler wraps its body in. So a static export is simply: render each page through the SAME path the server uses, strip the frame to recover the bare HTML bytes, and write them to dist/<path>/index.html. No database, no worker host, no socket.
cargo run(orcargo run -- serve) — open a database, register each page's render handler under itsqq.render.<id>key, insert a_sys_routesrow per page, and serve HTML over a worker. This is the default mode.cargo run -- build [outdir]— render every static page in-process and export a CDN-ready directory of plain files. No database, no socket. The output serves with no DMS running.
Running an export with quill build#
From inside a Quill app directory (the one with Cargo.toml), run quill build. This is a thin convenience wrapper: the CLI checks that a Cargo.toml exists in the current directory, then runs cargo run --release -- build, forwarding an optional output directory. The real export logic lives in the generated app's main.rs (function cmd_build), because it needs the app's own PAGES list.
# Default output dir is ./dist
quill build
# Override the output dir (two equivalent forms)
quill build public_site
cargo run --release -- build out
# `quill build` is exactly this under the hood:
cargo run --release -- buildThe generated app understands its own subcommands directly, so you do not need the quill CLI on your PATH to export — cargo run --release -- build works on its own. The app's argument handling: build [outdir] exports, serve or no args serves, and any other first argument is an error (exit code 2).
- Default output directory
dist(constantDEFAULT_OUT_DIRinmain.rs). Override by passing a directory argument.- Public asset directory
public(constantPUBLIC_DIR). Copied verbatim into the output directory.- Build profile
--release.quill buildalways passes--releaseso the exported binary render is optimized.
The export pipeline (cmd_build)#
Inside the generated main.rs, cmd_build walks the same PAGES list the server uses. For each page it calls the handler with an empty request ((page.handler)(&[])) — SSG pages are request-independent, so an empty request record is exactly what the prerender path passes. It then strips the __headers frame with qdms::workers::qquill::take_response_headers, which yields both the bare HTML body AND the headers, and recovers the page's Cache-Control from those headers. Finally it pairs each body with its route into an ExportPage and hands the whole set, plus an ExportPlan, to qquill_build::export_static.
PAGES (src/main.rs)
| for each page
v
(page.handler)(&[]) -- the SAME render path the server calls
| FunctionResponse { data: framed bytes }
v
take_response_headers(&mut data)
| data := bare HTML document bytes
| headers -> find "Cache-Control"
v
ExportPage::new(RouteDef::page(id, path), data)
.with_cache_control(cache_control)
|
v
export_static(&ExportPlan { out_dir, public_dir, not_found_html }, &pages)
|
+--> dist/<route>/index.html (one per static route)
+--> dist/<public copied verbatim>
+--> dist/_headers
+--> dist/_redirects
+--> dist/404.html
|
v
ExportReport { pages_written, skipped_dynamic, assets_copied }export_static is the pure std::fs writer half. It is std-only and DMS-free — it never calls page handlers or touches a database; it only owns the URL-to-file mapping, the public/ copy, and the Cloudflare control-file emission. It returns an ExportReport summarizing what it wrote.
| ExportReport field | Type | Meaning |
|---|---|---|
| pages_written | Vec<String> | Relative paths (under out_dir) of the page index.html files written. |
| skipped_dynamic | Vec<String> | Route paths skipped because they are dynamic (`:param` / `*rest`). |
| assets_copied | usize | Number of files copied from public/ into out_dir. |
A real export, end to end#
Scaffold a fresh app with quill new, then export it. The starter ships two pages (/ and /counter) and one public asset (favicon.svg). Here is the actual command and the actual program output, followed by the directory tree it produces.
Request
cd myapp
quill buildResponse
Exported 2 page(s) to dist/
dist/index.html
dist/counter/index.html
Copied 1 asset(s) from public/
Deploy dist/ to Cloudflare Pages — it serves with no DMS running.Output
dist/
├── index.html # GET / — rendered to dist/index.html
├── counter/
│ └── index.html # GET /counter — pretty URL: counter/index.html, not counter.html
├── favicon.svg # everything under public/ copied verbatim
├── _headers # per-route Cache-Control + immutable /assets/*
├── _redirects # placeholder (a pure static export needs none)
└── 404.html # served for unmatched paths (Cloudflare picks it up)The output lines come straight from cmd_build: one Exported N page(s) header, one indented line per page written, a Copied N asset(s) line when any public files were copied, one skipped dynamic route ... line per skipped dynamic route, and the closing deploy hint.
URL to file mapping (pretty URLs)#
route_to_file maps each route's URL path to its output file relative to the output directory. Routes are written as directory-style .../index.html (not counter.html), which is what gives clean URLs on a CDN with no redirect rule. The root path maps to a bare index.html.
| Route path | File written |
|---|---|
| / | dist/index.html |
| /counter | dist/counter/index.html |
| /blog/post | dist/blog/post/index.html |
| /counter/ (trailing slash) | dist/counter/index.html (collapses to the same file) |
The mapping is also defensive: any ., .., or empty path segment is dropped, so an exported file can never escape the output directory. For example /../etc/passwd maps to etc/passwd/index.html, and /.. maps to index.html — never outside dist/.
Byte-identical serve and build#
The bytes written to dist/<route>/index.html are byte-identical to what the live server would send for that route. This holds for two reasons. First, the export calls the same respond handler the server registers, so the rendered document is produced by the same code path. Second, the export writes page bodies and copied assets with fs::write/fs::copy (raw bytes), never round-tripping the HTML through the lossy QQL _sys_pages text path.
The in-DMS SSG counterpart (prerender_static) is content-addressed and deterministic: each page's ETag is a strong hash (quoted SHA-256 hex, via strong_etag) of its HTML body, so identical HTML yields an identical ETag (natural dedup), and a build-twice run yields byte-identical HTML plus identical ETags — the CI gate. For this to hold, render functions must be wall-clock-free and order-stable (use sorted/BTreeMap iteration, never a HashMap iteration order or a timestamp).
Cloudflare Pages control files#
export_static writes three Cloudflare Pages control files so the export deploys with no extra configuration.
_headers carries one stanza per exported page with that page's Cache-Control (the value take_response_headers recovered, falling back to DEFAULT_PAGE_CACHE = public, max-age=60), plus a trailing immutable rule for the content-hashed asset directory /assets/*. The page match path is the normalized URL (root stays /, a trailing slash is collapsed).
/
Cache-Control: public, max-age=60
/counter
Cache-Control: public, max-age=60
/assets/*
Cache-Control: public, max-age=31536000, immutable_redirects is a documented placeholder. A pure static export needs no rules (Cloudflare Pages serves 404.html for unmatched paths automatically), so Quill emits only a commented format reference you can extend, one rule per line.
# Cloudflare Pages _redirects — one rule per line:
# /old-path /new-path 301
# /spa/* /index.html 200
# A pure static Quill export needs none; 404.html handles unmatched paths.404.html is served by Cloudflare Pages for any unmatched path with no _redirects rule needed. By default (not_found_html: None in the ExportPlan) Quill writes its minimal built-in DEFAULT_404_HTML; supply an app page by setting not_found_html to that page's rendered bytes.
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>404 — Not Found</title></head><body><main><h1>404</h1><p>This page could not be found.</p><p><a href="/">Go home</a></p></main></body></html>| Constant | Value | Where it applies |
|---|---|---|
| DEFAULT_PAGE_CACHE | public, max-age=60 | Per-page Cache-Control when the handler set none |
| SSG_CACHE_POLICY | public, max-age=31536000, immutable | The /assets/* immutable rule in _headers; SSG page cache policy |
| ASSET_HEADERS_GLOB | /assets/* | The _headers match glob for content-hashed assets |
Copying public/ assets#
Everything under the app's public/ directory is copied verbatim into the output directory by copy_tree. This is where the favicon, images, and any static JS/CSS live; the same files are served by qq.asset when running live. The copy is byte-exact and recursive.
If public_dir is None or the directory does not exist, the copy step is simply skipped and assets_copied stays 0.
Deploying to Cloudflare Pages#
dist/ is a self-contained static site. There are two common ways to ship it.
Direct upload (no build on Cloudflare): build locally, then upload the directory as-is, e.g. with Wrangler.
quill build
npx wrangler pages deploy distGit-connected build: if Cloudflare Pages builds from your repo, set the build command and output directory. Cloudflare runs the command and then serves whatever lands in the output directory.
| Cloudflare Pages setting | Value |
|---|---|
| Build command | cargo run --release -- build |
| Build output directory | dist |
No extra configuration is needed: Quill writes _headers, 404.html, and _redirects for you, and Cloudflare reads them verbatim. There is no DMS and no server process in production — the CDN serves the plain files Quill exported.