The template language
Pages and routing
app/ and components/, companions, metadata, modes and the base path.
A project's pages live in app/, in directories that mirror the site's addresses, as in Next.js's App Router. Its own components live in components/.
The files
app/
layout.weft the root layout: every page renders where its `slot` is
page.weft /
page.rs the page's companion (optional)
about/page.weft /about/
blog/layout.weft wraps every page under /blog/, inside the root layout
blog/[slug]/page.weft /blog/:slug/, with `params.slug`
docs/[...path]/page.weft /docs/…, with the rest of the address as `params.path`
(marketing)/pricing/page.weft /pricing/: a group organises files and adds nothing to the address
_drafts/ a directory starting with `_` is never a route
not-found.weft what an address with no page shows
components/
date-badge.weft `DateBadge`
date-badge.rs its companion (optional)build.rs (weft_build::run()) reads both directories when the project builds, and weft::app!() brings them into src/lib.rs as the module weft_app. It holds the routes (weft_app::ROUTES), a registry with every page, layout and component added, and root(path), the component that renders a path.
The build stops with the file and line for a template that doesn't parse, a layout without a slot, two pages at one address, or a name that can't be part of a URL.
What a page or layout can read
| Name | Holds |
|---|---|
params | the route's parameters: params.slug |
path | the address as the browser has it (/blog/hello/, without ? or #) |
| a companion's names | whatever its scope provides |
| a layout's provided names | what provide in a layout around it lists |
A layout can mark the current link: aria-current: if path == link.href then "page" else null. Reading a parameter the route doesn't have is an error, so a layout shared by several routes reads one with get(params, "slug").
Companions
A page.rs or layout.rs beside a template is for what a template can't express: data from Rust, Rust functions, and anything that needs Rust's libraries. It can have:
| Item | For |
|---|---|
pub fn scope(cx: &PageCx) -> Scope | values, functions and handlers the template reads. The template's declarations go on top. cx.param("slug") reads a parameter, and cx.path() the address. |
pub fn static_params() -> Vec<Params> | which paths of a dynamic route to prerender, when static params can't list them |
pub fn title(params: &Params) -> Option<String> | a title the template's meta can't work out |
pub const INTERACTIVE: bool | whether the page needs the runtime, when the build can't tell |
A component's companion (components/date-badge.rs) has pub fn scope(props: &Props) -> Scope.
Metadata
meta(title: …, description: …) at the top of a page or layout sets the page's title and description. A page's own wins over its layouts'. A page's title can read params and the page's declarations:
use "../../posts.weft"
static params = posts |> map(p -> p.slug)
let post = post_for(params.slug)
meta(title: "{post.title} — Margins")Prerendered paths
static params = … in a dynamic page lists the paths to prerender, so the page needs no companion for them. Each item is the parameter's value (a list of segments for [...path]), or a record of the parameters for a route with several: { year: 2026, slug: "a" }. It reads the page's let and fn declarations, including those a use brings in. A page with no parameters can't have one, and neither can a page whose companion has static_params().
Matching
The most specific route wins: /blog/new/ beats /blog/:slug/, which beats /blog/*rest/. An exact path also beats a catch-all that would take it. Layouts stay mounted, with their state, while the address moves between pages they share.
State the pages share
provide cart, add in a layout lets every page and component inside it read cart and call add as if they were its own. A name the page declares itself wins. Since the layout stays mounted, the state lasts from page to page.
A layout's companion can share state too: it provides it with cx.props().provide("cart", cart.clone()), and a page reads it with cx.props().context::<Cart>("cart"). Kiln & Co.'s basket works this way.
Modes and the base path
In weft.toml:
[app]
mode = "hybrid"
base = "/blog/"| Mode | Output |
|---|---|
hybrid (the default) | a prerendered file per page. A page with nothing interactive ships as HTML and CSS only; the others load the runtime, then show pages without reloading when their links are clicked. |
mpa | a file per page, and every link loads its page |
spa | one page; the runtime renders every address in the browser |
base serves the app under a path: every route, its files and its 404.html. weft_app::ROUTES.href("tag/rust/") gives /blog/tag/rust/.
When the runtime handles a link, it updates the address, the title, the scroll position (or the #fragment) and focus, which goes to main for screen readers. Back and forward work. New-tab clicks, target, download, other sites and addresses without a page are left to the browser.
Pages rendered in the browser
A dynamic route without static_params, and every page of an SPA, is served by _shell.html. routes.json tells weft serve and weft dev which addresses go there, and weft build writes the same rewrites into vercel.json.