Skip to content

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

NameHolds
paramsthe route's parameters: params.slug
paththe address as the browser has it (/blog/hello/, without ? or #)
a companion's nameswhatever its scope provides
a layout's provided nameswhat 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:

ItemFor
pub fn scope(cx: &PageCx) -> Scopevalues, 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: boolwhether 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/"
ModeOutput
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.
mpaa file per page, and every link loads its page
spaone 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.