Static or single-page
The same source builds as pre-rendered pages or as one app shell. What each gives you, what the static paint knows, and how dynamic routes are rendered.
A web build comes in two shapes, chosen by one switch, build.ssg:
|
Static ( |
Single-page ( |
|
|---|---|---|
| What is written |
One HTML file per route — |
One |
| First paint | Immediate: the text is in the HTML | After the script loads and runs |
| Search engines and link previews | See the real page | See the shell (with its title and tags) until they run the script |
| Moving between pages | A real page load | Instant, no reload; focus and scroll handled, title announced |
| The host | Any static host, as it is |
Must answer every path with |
| Interactive |
Yes, once the script hydrates the paint in place |
Yes |
wf init -t static starts with ssg on; -t spa with it off. Change your mind at any time — the source is the same.
Choose static for anything people find by searching or share by link: marketing sites, docs, blogs, shops. Choose single-page for an app behind a login, where every page is personal and navigation speed matters more than a first paint — or when your host cannot serve a file per route.
Hydration
A static page is readable before any script runs. When app.js arrives it hydrates the page: it draws the page beside the one already there, then takes the painted one over in place. An element nothing holds on to stays exactly as it was painted — its attributes brought up to date, never moved or drawn again — and an element a handler, an effect, a ref or a widget holds is put where the painted one stood, inside the painted parent. Text a signal keeps current changes inside the painted element. The reader never sees the page flash or re-draw, and keeps their scroll, focus and anything they had selected. The page's largest paint stays the first one, which is what a search engine's speed score measures.
Under wf serve, the runtime compares the page it took over with the one it drew and says so in the console when they differ — a WebFluent bug, never yours.
What the static paint knows
The build evaluates the page to paint it, as far as it can know the values:
| Source | In the pre-rendered HTML |
|---|---|
|
Literals, |
Painted |
|
|
Painted |
| Stores, from their initial values | Painted |
| Components | Expanded, props substituted, slots filled |
|
|
In the default locale |
|
|
Computed, with a built-in table of locales |
|
|
The |
|
|
Nothing, until the action runs |
|
|
Unknown — a condition on them is drawn once live |
|
anything read from |
Unknown |
So for the first paint to be right, prefer what the build can see: data files over fetches for content that changes at deploy time, and responsive values over viewport for layout.
Static paths
A page on a :param route is written once per value its paths: names — one HTML file per post:
wf
type Post { slug: String, title: String, date: String, body: String }
data posts: [Post] = "posts.json"
page PostPage(path: "/posts/:slug", slug: String, title: "Post", description: "A post.", paths: posts.map(p => p.slug)) {
derived post = posts.find(p => p.slug == slug)
head { meta(property: "og:title", content: post?.title ?? "Post") }
if let p = post {
Heading(p.title).h1
Markdown(p.body)
} else {
Alert("No such post").warning
}
}
paths:must be knowable at build time: adatafile, aconst, a literal list.- For a route with several parameters, each value is a map naming them:
paths: pairs.map(x => { year: x.y, slug: x.s }). - Each value is seeded as the parameter (and
params.slug), painted, and listed in the sitemap. title:anddescription:may name the page's parameters —title: "{slug} — Blog"— and each file gets its own. A title may also look the entry up —title: "{posts.find(p => p.slug == slug)?.title ?? slug} — Blog"(Content).- A value that is not in
paths:has no file, so a static host answers with404.html. In a single-page buildpaths:is not needed at all.
The 404 page
page NotFound(path: "*") is what the router shows for a route no page claims, and in a static build it is also written as 404.html, which GitHub Pages, Netlify, Cloudflare Pages and most static hosts serve for a path they have no file for.
Serving a single-page build
Every path a reader might open — /users/42 — has to reach index.html. Most hosts need one line for it; Deploying has each:
text
Netlify / Cloudflare Pages public/_redirects: /* /index.html 200
nginx try_files $uri $uri/ /index.html;
Vercel "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }]
Without it, the home page works and a reload anywhere else is a 404.
Under a sub-path
build.base_path — "/my-site" for a GitHub Pages project site — prefixes every link, every asset and the router's paths. Write your paths from the site's root as if there were no prefix: Link("About", to: "/about").
On this page
Hydration What the static paint knows Static paths The 404 page Serving a single-page build Under a sub-pathChecked by the test suite
Every code block in the guide is parsed, checked and type-checked on each release.