Static-site export
GoFastr is SSR-first: every page is fully server-rendered on first paint, and interactivity is server-driven RPC over islands. A static export renders the whole app once, at build time, into a directory of plain HTML
- assets you can host on any static file server (GitHub Pages, S3, Netlify,
python3 -m http.server). No Go server runs in production.
This replaces the old approach of crawling a running server with wget,
which broke client interactivity: wget baked the cache-bust ?v=<hash>
query into each split runtime module's filename, so the static host
served a 404 for every module and silently killed the theme toggle,
command palette, copy buttons, and widgets.
Exporting
Disk-backed core/static.Handler instances release their directory handles
between requests, including on Windows. Each request checks the original
directory identity; replacing that directory makes the handler return 404.
Create a new handler to serve the replacement directory. The handler
resolves symlinks once, when it is built, so after a symlink flip it keeps
serving the old target while that directory exists.
framework.App.ExportStatic(ctx, dir, basePath) drives the app in-process,
with no port and no crawl: it enumerates every declared route, renders each
through the SSG-aware render path, and dumps all /__gofastr assets with
query-free filenames. basePath is the URL subpath for a project-site
deploy ("/gofastr"); pass "" for an apex deploy.
Generated locale alternates and x-default include that mount path, just
like sitemap URLs. A sitemap BaseURL already ending in the mount path
does not duplicate it. Explicit screen-provided alternates remain unchanged.
For direct UIHost.RenderStaticPage / RenderStaticPageResult calls, pass
uihost.WithStaticBasePath(ctx, "/gofastr") to supply the same mount path.
Static client navigations preserve the incoming page's language and
direction markers, including when only a layout slot swaps. Entering an
RTL locale loads the direction behavior on demand and updates <html dir>;
returning to a cached LTR page restores its direction without a full reload.
fwApp, _ := framework.NewApp(opts...) // "" = apex; "/gofastr" for a https://<user>.github.io/gofastr/ deploy. if err := fwApp.ExportStatic(context.Background(), "_site", ""); err != nil { log.Fatal(err) }
gofastr export <dir> does this for any app without code: it runs the
app's main package with GOFASTR_EXPORT=<dir> (and
GOFASTR_EXPORT_BASE from --base), and App.Start exports after
migrations, seeds and battery init, then returns instead of serving.
Pages that read the database render the seeded rows. The app's main
has to return once Start does, which the generated main.go does.
gofastr export --base /gofastr _siteAn exit status of 0 does not prove the export ran: a main that returns
before Start exits 0 too, over an empty or stale directory. So the CLI
also sets GOFASTR_EXPORT_RUN to a fresh run id, and Start writes it
back in <dir>/.gofastr-export.json (framework.ExportReceiptFile)
once every page is on disk. The CLI checks the id, prints the page
count, and deletes the receipt so it does not ship with the site. For an
app on GoFastr v0.96.0 or later (or a package inside this repository), a
missing receipt fails the export. An older pin or a replace directive
cannot be told apart from an app that never exported, so the CLI warns
and keeps the exit status as the only signal. An app that sets
GOFASTR_EXPORT itself, without GOFASTR_EXPORT_RUN, gets no receipt.
A registered battery that implements static.Contributor sees every
rendered page and adds files of its own; site search
writes its index this way. A contributor file at a page's path, in any
case, fails the build, and so does a contributor file, rail script,
static-dir or ExtraDirs file whose path holds a backslash (a separator
on Windows). A build that writes two files differing only in case (a
contributor's /_HEADERS beside the generated _headers, a rail script
/REDUCERS.js beside a static reducers.js, a static ABOUT/logo.png
beside the page about/index.html) fails and names both: a
case-insensitive filesystem keeps one spelling, and a case-sensitive
host then answers 404 for the other. A static file that replaces a
generated one (manifest.webmanifest, robots.txt) must match its
spelling exactly. A rebuild into the same directory renames a file or
directory an earlier build left in another case to this build's
spelling, so an export built on macOS deploys to a case-sensitive host
unchanged; only an entry that is the build's own file under the other
name moves, and nothing past a symlink does: an entry behind a symlink
in another spelling fails the build and is named, as does a symlink
whose target names a directory in a spelling the disk no longer has. A contributor may not
write _headers or _redirects, which the build writes itself; ship
extra rules in the static dir, where they are appended. Exports are
reproducible: two runs over the same content write the same bytes (a
page that shows live data aside), the PWA worker included, in a fresh
directory or over the last build.
The example site also wires a --export <dir> flag so the same binary
serves live or exports:
go build -o site ./examples/site/ ./site # live server ./site --export _site # static export → ./_site
What gets emitted
- One
index.htmlper route (/→index.html,/about→about/index.html,/products/:slug→products/<slug>/index.html). Static hosts serve a directory index at the trailing-slash URL and answer/aboutwith a 301 to/about/, so everyhref,actionanddata-cui-push-statethat names an exported page is written as/about/(query and fragment kept). A click, client navigation included, then costs one request instead of two. The same rewrite covers each hidden widget's dumped chrome, the PWA offline page and the sitemap's<loc>URLs. Links to anything else (assets, routes the export skipped, absolute URLs) are left as written. /__gofastr/runtime.js: the runtime core.404.htmlat the export root: the not-found page the live server answers with, rendered through the app's root layout (see Error pages below). GitHub Pages, Netlify, Cloudflare Pages and S3 website hosting all serve it for a miss; configure nothing, it is already the name they look for./__gofastr/color-scheme.js: the FOUC-prevention bootstrap loaded synchronously at the top of<head>. Without it the stored scheme is applied only whenheadless-navigationloads, so a reload flashes./__gofastr/runtime/<name>.js: each split runtime module (widgets,headless-feedback,headless-navigation, …), one file per module./__gofastr/app.cssand/__gofastr/comp/<name>.css: global and per-component stylesheets.- Per-route
llm.md(unlessNoLLMMDis set). The exportedllm.mdindex links only the docs the export wrote: a dynamic route lists its pattern followed by a link to each exported page's doc, and a page whose doc was not written appears without a link. The builder hands the written docs to the index throughapp.WithExportedDocs(ctx, docs). Each page's doc renders under the same synthetic request as its HTML, so a screen that reads the request works in both. A page's doc is the one the live host serves, SEO front matter included: the builder writes it throughUIHost.LLMMDWithSEO. .gofastr-pages: the list of page andllm.mdfiles this build wrote. The next build into the sameOutDirremoves any listed file it no longer writes, such as a page that became gated or a route that was dropped, so the static host stops serving it with a 200. A file the builder never listed, and anything this build wrote or copied, stays; a first build with no list removes nothing. A page renamed by case only (/Aboutto/about) is the same file on a case-insensitive filesystem, so its old spelling is not removed there.- With
uihost.WithSitemap/uihost.WithRobots:sitemap.xmlandrobots.txt, built by the live handlers' code (same route expansion viaStaticPathsProvider, sameExcludePaths, same AI-bot rules). The sitemap lists only the pages the export wrote: a page it skipped (a gated screen, anExcludeRoutesroute, a 404-outlet route, a page that answers a status other than 200, a path a redirect takes) has no file on the static host, so its<url>entry is dropped, in every locale, and so is every hreflang alternate naming it. The host asks about each page by the path the export wrote it under, so aStaticPathsthat answers differently from one call to the next can drop an entry but never list a page the export did not write. Past 50,000 URLs the sitemap is an index with its parts undersitemap/<n>.xml. Under--export-baseevery sitemap<loc>and the derivedSitemap:line in robots.txt include the subpath, once even when the sitemap'sBaseURLalready ends in it. Asitemap.xmlorrobots.txtin the app's static dir wins over the generated one (a user'ssitemap.xmlreplaces the generated parts too). Note that on a subpath deploy (e.g. a GitHub Pages project site) crawlers only honor a robots.txt at the origin root; the file is still emitted for apex/custom-domain deploys. - With
uihost.WithPWA:manifest.webmanifest,service-worker.js,__gofastr/pwa/register.js, and__gofastr/pwa/offline/index.html(the offline page carries the export's CSP meta tag, as every page does). Under--export-basethe manifest'sstart_url/scope/id/icon paths, the worker's precache and deny lists, and the registration target are all prefixed, so the exported app installs and works offline from the subpath.
A static export gets the full-site worker, not the live app's
conservative one: the exported page set is closed and immutable, so
the worker precaches the whole site at install: every page, every
framework asset (widget chrome, llm.md, component stylesheets under
their versioned ?v= URLs), the shell. It serves navigations
cache-first, tolerating static hosts' trailing-slash redirects. A
visitor who lands once has the entire site cached; install the PWA
and every page works offline, including pages never visited. User
static-dir files are precached best-effort so one un-servable file
cannot brick the install. The worker's cache version fingerprints the
exported content, so a redeploy (even one that only edits page text)
ships byte-different worker JS and the fresh export replaces the old
cache; an unchanged rebuild reproduces identical worker bytes. Static
caches live under their own gofastr-pwa-static-… prefix, so a live
deployment on the same origin never deletes them. Denied endpoints
(/api, /auth, framework session/SSE paths) are still never
precached or intercepted, and a user-supplied manifest.webmanifest
or service-worker.js in the static dir wins over the generated one.
_headers(Netlify, Cloudflare Pages) with the Content-Security-Policy,X-Content-Type-OptionsandReferrer-Policy, and the same policy as a<meta>in every page; see Content-Security-Policy below.- For each
app.Redirect, a page at the old path and a_redirectsrule; see Redirects below. - Every file in the app's static dir (or embedded static FS), copied
to the same path,
index.htmlfiles included. Two kinds of file are refused: one the UI host would not serve (a path with a dot segment such as.envor.git/config, a server config file name such asweb.config, or a symlink whose target leaves the static dir or is neither a regular file nor a directory), and one at the path of a page the build rendered, in any case (about/index.htmlorAbout/index.htmlbeside an/aboutroute). The build log names each. A symlinked directory that stays inside the static dir (static/images -> img) is copied under its own name, as the UI host serves it; a link back to a directory it sits in (loop -> .) is skipped and logged. Files under a leading.well-known/, unless they are dotfiles themselves, and the root.nojekyllGitHub Pages reads are copied. A static-dir_headersor_redirectsis appended to the generated one, so its rules apply and the generated ones (the security headers and theapp.Redirectrules) stay first. One spelled in another case (_HEADERS) is refused: no host reads it, and on a case-insensitive filesystem it would replace the generated file. Builder.ExtraDirsfiles under their mount path, minus the dotfiles and config files the static dir also skips. A page or asset the build wrote, in any case (the generated_headersand_redirectsincluded), or a file the static source provides, wins over an extra file at the same path. An extra file spelled in another case than an asset the build wrote fails the build. Which file wins is decided by this build and the static source, never by what an earlier build left in the output directory, soBuilder.Watch(which rebuilds into the same directory) picks up a changed extra file.WatchleavesOutDirout of what it polls, so the output directory may sit under a watched one (watch., write./dist) without each rebuild triggering the next.
In an app that routes by locale prefix (framework.WithLocalePrefixes)
each prefixed locale gets its own page tree (/es/about/index.html)
and its page links are prefixed (/es/about/). The static dir and
ExtraDirs are copied once, to the export root, so a link from a
Spanish page to /files/report.pdf keeps its root path; so does a link
to any path that no route or redirect takes. A link is matched in its
decoded form, so an escaped /caf%C3%A9 to a /café route is a page
link and takes the prefix. A copied file wins over a route that also
matches its path, as it does on the live server.
Dynamic routes require the screen to implement StaticPathsProvider, the
static-export analogue of Next.js's generateStaticParams. Each returned
param map is substituted into the route pattern to produce one concrete
index.html:
type StaticPathsProvider interface { // One param map per concrete URL to emit. // {"slug": "go"} → /posts/go/index.html StaticPaths(ctx context.Context) []map[string]string } // A docs catch-all emits one page per slug. The param key is the bare // catch-all name ("path"), matching the route pattern /docs/{path...}. func (s *DocScreen) StaticPaths(ctx context.Context) []map[string]string { out := make([]map[string]string, 0, len(catalog)) for _, slug := range catalog { out = append(out, map[string]string{"path": slug}) } return out }
A value fills one URL segment, escaped the way the sitemap links it: the
key a/b on /orders/{id} is the page /orders/a%2Fb. A static host
decodes that URL to orders/a/b, so the export writes the page to
orders/a/b/index.html. The build refuses a value whose file would land
outside its route's directory: an empty, . or .. path component on
disk, a NUL, or a backslash (a Windows separator). It also refuses two
pages that land on one file, such as that key beside a route
/orders/a/b: the error names both paths, where the later page would
otherwise replace the earlier one without a word. Two files that differ
only in case (/orders/A beside /orders/a) count as one, since a
case-insensitive filesystem (macOS, Windows) stores them as one, and so
do two that differ only in Unicode normalization (café precomposed or
with a combining accent), which APFS stores as one. A
redirect's page is checked the same way, against the pages and the
other redirects.
Routes whose screen doesn't implement StaticPathsProvider (or returns an
empty slice) are skipped at build time, but the builder now logs a
WARN naming the route pattern and the fix (implement StaticPaths) rather
than dropping it silently; the missing pages are a build-time signal, not a
silent gap. The route is still reachable via SSR if the server is running.
A screen whose policy Redirects or Blocks (the two non-Allow
decisions) is skipped too: there is no request or session at build time, so
a redirect to /login or a hard 403 has nothing to render. The builder logs
a WARN naming the route and the policy decision and continues; the page is
simply absent from the export, and the route stays reachable via SSR on the
live server. A screen that should ship in the export behind a gate uses a
RenderAlt policy instead: the alt component (a login prompt, a public
teaser) renders in place of the gated screen and is exported like any other
page. Allow and RenderAlt are the only decisions a static export can
materialize, so a Redirect/Block screen is never written to disk.
A page that answers a status other than 200 is skipped the same way:
a component called app.SetStatus while it drew (entityui's Record
for a missing row), or the screen declares ScreenStatusCode. A
static host serves every file with a 200, so writing the page would
tell every visitor it exists. The builder logs a WARN naming the
route and the status (uihost.StatusPageError), and writes no
llm.md for that page either.
Content-Security-Policy
A live app sends its Content-Security-Policy as a response header
(core/middleware.SecurityHeaders). A static host sets no headers of
its own, so the export carries the policy two ways:
- a
<meta http-equiv="Content-Security-Policy">right after<head>in every page, which every browser enforces on any host; - a
_headersfile at the export root, which Netlify and Cloudflare Pages turn into a real response header.
The policy is the one the app serves live:
AppConfig.SecurityHeaders.ContentSecurityPolicy (set with
framework.WithSecurityHeaders), or the built-in default
(default-src 'self'; img-src 'self' data:; object-src 'none';
form-action 'self'; frame-ancestors 'none'; base-uri 'self') when it
is unset. App.ExportStatic passes it as
static.Builder.ContentSecurityPolicy; a builder built by hand sets
that field. An app that allows font-src https://fonts.gstatic.com
live gets the same font host on the static site.
_headers carries the value as written. The <meta> differs in two
ways, because a policy delivered in a <meta> behaves differently
(CSP Level 3, section 3.1):
frame-ancestors,report-uriandsandboxare left out of the meta: a browser ignores them there and logs a console warning for each. The clickjacking guard (frame-ancestors) therefore applies only on hosts that read_headers.- A comma-separated list of policies becomes one
<meta>per policy. A single meta parses one policy, so a list written into one would fold the second policy's sources into the first's last directive and allow more than either.
The build fails on a policy it cannot write safely:
- a
'nonce-…'source. A server mints a nonce per response; written into files, every visitor gets the same value, and an injected script can carry it. Exported pages link every script, so no page needs one. Drop the nonce source (hashes are fine). - a control byte (a line break would add a header line of the
policy's choosing to
_headers), or a",<or>, which no CSP source uses and which would end the meta's attribute.
Redirects
app.Redirect("/old", "/new") answers /old with a 308 on the live
server. A static host has no route for it, so the export writes
old/index.html: a <meta http-equiv="refresh"> to the target and a
plain link to it as the fallback, noindex, with the export's CSP and
no script or style. The target is the end of the redirect chain, in
the form the export links pages (/new/), under --export-base when
one is set. In an app that routes by locale prefix each locale tree
gets its own page, and its target follows the rule page links follow:
a page target takes the tree's prefix, while a static file, a path no
route takes, or a target that already names a locale (/es/about)
keeps its path.
The export also writes _redirects with one /old /new/ 308 rule per
redirect, which Netlify and Cloudflare Pages (the hosts that read
_headers) answer with a real 308. A static-dir _redirects is
appended after these rules, and both hosts apply the first rule that
matches, so an app.Redirect wins over a static rule for the same
path. An app with no redirects writes none, removes one an earlier
build left in the output directory, and copies the static dir's
_redirects as it is.
An exact redirect may take one concrete path of a dynamic screen
(/products/alpha beside /products/:slug). The live router answers
that path with the redirect, so the export writes the redirect page
there, not the screen's page, and leaves the path out of
Result.Pages, llm.md and the sitemap.
app.RedirectPattern matches URLs no single file can stand for. The
export skips it with a WARN naming the pattern; its URLs answer the
export's 404.html, and the live server keeps redirecting them. A
redirect chain that never ends (the live router refuses it too) is
skipped the same way.
Failed fills refuse the export
A tree-layout outlet fill whose Load or render fails can be contained
on a live server: the outlet degrades to its fallback (or renders empty) and
the page serves with status 200. An export must not freeze those degraded
bytes into a page every visitor would see, so the builder reads the render's
contained-failure record (RenderResult.FillFailures, surfaced through
UIHost.RenderStaticPageResult) and fails the build, naming the route
and each outlet address:
static: render "/broken/load" refused: 1 fill(s) failed and were contained (a live server degrades the outlet; the export will not bake it): l:shell#aside (boom: ...)
Fix the fill, or keep the route out of the export set on purpose with
Builder.ExcludeRoutes (exact pattern or a whole path-segment prefix —
"/broken" excludes /broken/load and every other /broken/* page; each
skip is logged). ExcludeRoutes is a builder field, so
App.ExportStatic (which constructs the builder for you) exports every
route: drive static.Builder directly when a route set needs exclusions.
Scripts the app serves itself
Every page carries the extra-script rail (uihost.WithExtraScripts,
RegisterExternalScript, and RegisterDocumentScript on the pages its
scope accepts). A rail script the build has not already written, such
as one the app serves from its own router
(WithExtraScripts("/__site/reducers.js") plus a Router().Get for
that path) or the plugin broker, is fetched through Builder.Handler,
query string included, and written into the export under its path. A
document-scoped script is fetched only when its scope accepts a route
the export rendered. A copy left in a reused output directory by an
earlier build is replaced; two srcs that differ only in their query
share one file, and the first wins. A src that is not a same-origin
path (a CDN URL, a protocol-relative or relative src) is left to the
browser and never fetched. App.ExportStatic sets Handler to the
app's router. A rail script that does not answer 200 fails the build,
since every exported page would 404 on it, and so does one whose path
is an exported page. A static.Builder built by hand with no Handler
skips these scripts.
SPA navigation reads whole documents as envelopes
A static host serves whole HTML documents and ignores the runtime's request headers, so every SPA navigation answer is a full page, not a partial. The runtime handles this natively: it derives the swap boundary from the fetched document's own layout chain (the deepest layer the live DOM shares with it), swaps only that layer's content cell, and applies every outlet and route area outside it as a fill — the same treatment a served envelope partial gets. Layout state (a kept list pane and its scroll) survives navigation and Back exactly as on a live server.
Error pages: 404.html and non-OK navigation
The export writes 404.html at the root, rendered from the same
not-found page the live server serves: the app's root layout with its
header and nav, outlets at their declared defaults, and the error body
in <main>. Every major static host serves a file with that exact name
for a miss — GitHub Pages, Netlify, Cloudflare Pages, and S3 website
hosting — so a visitor who lands on a URL the export does not hold gets
the site's own error page, not the host's default one.
The runtime handles the 404 status on purpose. A navigation answer
whose Content-Type is text/html — a static host's 404.html, or a
live server's error page — flows through the same apply paths a 200
takes: the swap lands at the deepest layer the live DOM shares with the
error document (the root shell), so the header stays and <main> shows
the error page. The URL keeps the target (Back returns to where the
visitor was), the title comes from the response, and the error page is
never written to the screen cache.
An error body that is NOT HTML (a plain-text 404 page not found from
a host that serves nothing custom, a JSON API miss) cannot be applied;
the runtime then shows a toast naming the status — Could not load /x
(HTTP 404) — and stays on the current page. Only a failed fetch
(offline, DNS) keeps the older "check your connection" wording. There
is never a full-page-reload fallback.
Routes whose fills fail on purpose (see Failed fills refuse the export) are absent from the export; clicking their links on the static host lands on this 404 page, which is exactly the honest answer for a static copy.
Static mode: what works, what's disabled
Every exported page is stamped with <html data-cui-static>. The runtime
uses its static composition and skips server-backed
dispatches so a click on a dead demo doesn't fire a request that 404s
against the serverless host:
| Feature | Static export | Why |
|---|---|---|
| Theme toggle, color scheme | ✓ works | client-only (color-scheme.js) |
| Copy-to-clipboard | ✓ works | client-only module |
Signal mutations (set/inc/toggle) | ✓ works | client-only |
| SPA navigation | ✓ works | fetches pre-rendered pages |
data-cui-rpc (island round-trips) | disabled | needs the Go handler |
| Compiled server actions | disabled | user-triggered calls show "Needs the Go server." without posting; automatic mount actions are skipped on hydration, navigation and inserted content |
data-cui-open (modals, ⌘K palette) | ✓ works | the export writes the widget catalog (widgets.json) and each widget's chrome and style sheet; an unregistered widget shows a "Needs the Go server." toast. A widget with RequireSession or RequireAuthenticated gets its style sheet and no chrome file, since the live /chrome endpoint refuses an anonymous caller and every exported file is public |
Palette search (Search: true) | ✓ works | reads the site search index files |
| SSE islands | not emitted | the SSE <meta> is omitted at render time |
A dismissible "Static preview — run locally" banner (using the shared
framework/ui.Banner component) is injected so server-backed demos
read as intentionally inactive rather than broken. The dismissal persists
in localStorage.
Client-only action handlers still run. Live pages use the full runtime, so every static-mode guard is a no-op in the normal server-backed app.
Deploying to GitHub Pages
- name: Export static site run: | # --export-base /gofastr: this repo is a Pages *project* site served # from https://<user>.github.io/gofastr/, so assets/nav/runtime-constructed # URLs must resolve under the /gofastr mount path. Omit for an apex deploy. ./site --export _site --export-base /gofastr touch _site/.nojekyll # __gofastr/ starts with _; Jekyll would drop it - name: Upload Pages artifact uses: actions/upload-pages-artifact@v3 with: path: _site
Subpath (--export-base) vs apex
An apex deploy (https://<user>.github.io/ or a custom domain) serves the
artifact at the host root, so the framework's root-absolute /__gofastr/…
URLs work as-is; omit --export-base.
A GitHub Pages project site (https://<user>.github.io/<repo>/) serves
the artifact under a subpath. Pass --export-base /<repo> and the builder:
- prefixes every root-absolute
src/href/action/data-cui-push-statein the HTML (assets, nav links, form targets, palette destinations); - prefixes the inline component-catalog
stylePathJSON values the runtime lazy-loads; - bakes the prefix into the emitted
runtime.js(it constructs split-module URLs in JS).
External links (https://…), protocol-relative (//host), fragments (#…),
and relative URLs are left untouched. Code samples are safe: core/markdown
escapes quotes inside <code> to ", so the attribute/JSON patterns
only match real markup.
Common mistakes
- Crawling instead of exporting. A
wget --mirrorof a running server is the trap this feature replaces. The cache-bust?v=<hash>query lands in the on-disk filename (headless-feedback.js?v=…), the static host strips the query, looks for bareheadless-feedback.js, and 404s, so zero modules load and all client interactivity silently dies. Always useExportStatic. - Forgetting a
StaticPathsProvideron a dynamic route. A/posts/:slugroute with no provider emits nothing; the build logs aWARNand skips it. ImplementStaticPaths(ctx)returning one param map per concrete URL. - Expecting server-backed islands to work. RPC round-trips, the widget catalog, and SSE need the Go server. Static mode disables them on purpose (no-op, no 404). If a page's value is its live interactivity, host the binary instead of exporting.
- Deleting the run-locally banner. It's the honest signal that a dead button is disabled-by-design, not broken. Keep it (or replace it with your own notice); don't ship a static export where clicks silently do nothing with no explanation.
- Letting
__gofastr/be dropped by Jekyll. GitHub Pages runs Jekyll by default, which ignores_-prefixed directories.touch _site/.nojekylldisables that for the deploy.