Pages and routing
A page is a route and what it shows. Pages own their paths; the app shell frames them; a layout wraps them.
A page is a route and what it shows. Pages own their paths: there is no route table to keep in step with them.
Declaring a page
wf
page About(path: "/about", title: "About us", description: "Who we are and why we build this.") {
Container {
Heading("About us").h1
Paragraph("We are three people and a dog.")
}
}
The header takes these attributes:
| Attribute | Required | Meaning |
|---|---|---|
|
|
yes |
The route: |
|
|
strongly |
The |
|
|
strongly | The meta description and the snippet a search result and a link preview show (~150 characters) |
|
|
The image a shared link previews with, site-relative or absolute | |
|
|
|
|
|
|
|
|
|
|
The component that frames the page (below) | |
|
|
An expression that must hold for the route to render | |
|
|
Where the reader goes when the guard fails (default |
|
|
|
The values a static build renders a |
|
|
|
A route parameter, typed (below) |
A page without a title or description builds with a warning (S01, S02); the compiler cannot write those for you, and a page without them ships unshareable.
The app shell
app declares what every page renders inside — a navbar, a footer, the Router where the current page goes:
wf
app {
Navbar {
Navbar.Brand { Link("Acme", to: "/") }
Navbar.Links {
Link("Docs", to: "/docs").prefix
Link("Pricing", to: "/pricing")
}
Navbar.Actions { Button("Sign in").sm { on click { navigate("/login") } } }
}
Router
Footer { Text("© Acme").muted.sm }
}
Router is bare — it takes no children — and the table is built from every page's path, ordered by specificity (static segments before :params, * last), so declaration order can never shadow a route. A project with no app declaration gets a bare router.
Router(transition: .fade, duration: "200ms") animates page changes (chapter 20).
Links and navigation
wf
page Nav(path: "/nav") {
Link("Home", to: "/") // an <a>; the router handles the click
Link("Guide", to: "/docs").prefix // also active on /docs/anything
Button("Go") { on click { navigate("/about") } }
}
The link whose to: matches the current route carries the class .active and aria-current="page"; .prefix makes a section link active for the routes beneath it. navigate(path) moves in code. Write paths from the site's root — "/about" — even when the site is served under a sub-path: with build.base_path set to "/docs", every Link, navigate and asset is prefixed for you. In a static build, links are real page loads and navigate is too; in a single-page app they swap pages without a reload, move focus to the new page's heading, reset the scroll and announce the title.
Route parameters
A :name segment is declared as a typed parameter of the page and read as a plain name, or as params.name:
wf
type Post { slug: String, title: String, body: String }
store Posts {
state all: [Post] = [
Post(slug: "hello", title: "Hello", body: "First post."),
Post(slug: "again", title: "Again", body: "Second post.")
]
}
page PostPage(path: "/posts/:slug", title: "Post", description: "One post.", slug: String) {
use Posts
derived post = Posts.all.find(p => p.slug == slug)
if let p = post {
Heading(p.title).h1
Paragraph(p.body)
} else {
Heading("No such post").h1
Text("Nothing lives at /posts/{params.slug}.")
}
}
A parameter is a String unless you say otherwise (id: Number). A static build renders a :param page once per value named in paths: — see Data.
Query strings and fragments
query is the URL's query string as a map, and hash its #fragment; both are kept current by the runtime, so a page that reads them follows the address:
wf
page Products(path: "/products", title: "Products", description: "Everything we sell.") {
derived tab = query.tab ?? "all"
derived sort = query.sort ?? "name"
Heading("Products").h1
Row(gap: .sm) {
Link("All", to: "/products?tab=all")
Link("On sale", to: "/products?tab=sale")
}
Text("Showing {tab}, sorted by {sort}; section: {hash}")
}
A query string is not part of the route: /products?tab=sale is the /products page. To change it from code, navigate("/products?tab=sale"). In the static paint query and hash are unknown, so what depends on them is drawn once the page is live.
Layouts
A layout: is a component with a default slot; the page renders in it. Use it for a section's own chrome — a docs sidebar, an app rail — where the app shell is the site's.
wf
component DocsShell(_ crumb: String) {
slot
Row(gap: .lg) {
Sidebar {
Sidebar.Item(to: "/docs") { Text("Guide") }
Sidebar.Item(to: "/docs/reference") { Text("Reference") }
}
Stack {
Breadcrumb {
Breadcrumb.Item(to: "/docs") { Text("Docs") }
Breadcrumb.Item { Text(crumb) }
}
children
}
}
}
page Guide(path: "/docs", title: "Guide", description: "The guide.", layout: DocsShell("Guide")) {
Heading("Guide").h1
}
page Reference(path: "/docs/reference", title: "Reference", description: "The reference.", layout: DocsShell("Reference")) {
Heading("Reference").h1
}
The layout's arguments are checked like any component call. The layout component is required to declare a slot (its default slot); a layout without one is an error, since the page would have nowhere to go.
Guards
guard: names an expression — usually a store's derived value — that must be true for the route to render; otherwise the reader is sent to redirect:.
wf
store Auth {
state user = null
derived loggedIn = user != null
action login() { user = { name: "Sam" } }
action logout() { user = null }
}
page Account(path: "/account", title: "Your account", description: "Your account.", guard: Auth.loggedIn, redirect: "/login") {
use Auth
Heading("Welcome back").h1
Button("Sign out") { on click { Auth.logout() } }
}
page Login(path: "/login", title: "Sign in", description: "Sign in.") {
use Auth
Button("Sign in").primary { on click { Auth.login() navigate("/account") } }
}
The guard is evaluated on every navigation, in the browser. A static build still pre-renders the page's HTML (the guard is a client-side gate, not a secret), so keep anything private behind an API the page fetches.
Per-page head tags
A page may add tags of its own to the document's <head>; they are painted at build time where the value is known, kept current on the live page, and gone when the page is:
wf
page Share(path: "/share", title: "Share", description: "A page with its own tags.") {
state image = "/og-share.png"
head {
meta(property: "og:image", content: image)
link(rel: "canonical", href: "https://example.com/share")
script(src: "/analytics.js", defer: true)
}
Heading("Share").h1
}
title and description are page attributes, not head tags; the compiler writes the standard tags (charset, viewport, description, canonical, Open Graph, Twitter card, JSON-LD) from them and the config.
The catch-all page
path: "*" is the page a static host serves as 404.html and the router shows for a route no page claims:
wf
page NotFound(path: "*", title: "Not found", description: "Nothing lives here.", noindex: true) {
Container {
Heading("Nothing here").h1
Link("Back home", to: "/")
}
}
Set-up code: what runs when a page opens
A page's body is a render block: it declares state, derived values and actions, and lists what to show. Everything at the top of the body runs once when the page renders; a call at the top — Store.load(id) — is set-up code.
wf
store Deploys {
state rows = []
action load(id: String) { rows = [{ id: id, ok: true }] }
}
page Deploy(path: "/deploys/:id", title: "Deploy", description: "One deploy.", id: String) {
use Deploys
Deploys.load(id)
for row in Deploys.rows by row.id {
Text("{row.id}: {row.ok}")
}
}
The life of a page, in order:
- The route matches, the page's chunk loads (in a split build) and its stylesheet with it.
- The body runs once:
states take their initial values, set-up calls run, elements are created. There is no re-render after this — what reads a state updates on its own. effects run, and run again when what they read changes; timers start.- The page is placed. In a single-page app, focus moves to its heading, the scroll goes to the top and the title is announced.
- When the route changes, everything the page created — effects, timers, listeners, connections,
.routestores — is disposed of, and theircleanup { }blocks run.
There is no onMount to write: set-up code runs at step 2, an effect at step 3, and after(0) { … } once the page is on screen.
On this page
Declaring a page The app shell Links and navigation Route parameters Query strings and fragments Layouts Guards Per-page head tags The catch-all page Set-up code: what runs when a page opensChecked by the test suite
Every code block in the guide is parsed, checked and type-checked on each release.