Routing
.tibi under pages/ becomes a route. The router is the in-house tibi-match trie wrapped in a tower::Service, so the same route table feeds tibi build (SSG), tibi serve (per-request), and any custom main.rs mounting Tibi into Axum / Hyper / Warp.File → URL
pages/
index.tibi → /
about.tibi → /about
blog/
_layout.tibi → wraps every sibling + descendant
index.tibi → /blog
[slug].tibi → /blog/:slug (single-segment param)
@[page].tibi → /@:page (param inside a segment)
[...rest].tibi → /blog/* (catchall, swallows the rest)
api/
notes/[id].ts → /api/notes/:id (server endpoint, see Server routes)
_404.tibi → renders when no route matches
_500.tibi → renders when a handler errors
Conventions:
index.tibiin any directory maps to that directory's URL (no/indexsuffix).[name]= single-segment dynamic param. Reject with a 404 iftibi-matchcan't insert (e.g.[*slug]— use the catchall form below instead).@[name],[name].json,v[name]-beta= a param with static text around it, inside one segment (one param per segment).pages/@[page].tibianswers/@vinnywithparam_page = "vinny". A static segment beats it, and it beats a plain[slug], so/@vinnyand/vinnycan go to different pages.[...name]= catchall; matches one or more remaining segments. Also answers the parent/when no other sibling claims it._layout.tibiat any level wraps every sibling + descendant via the layout's<slot/>. Override semantics: a nested_layout.tibiREPLACES any ancestor_layout— they don't compose. Sopages/_layout.tibionly wraps the root page (and any subdir page that didn't define its own), andpages/blog/_layout.tibifully owns the/blogchrome. To make nested layouts compose (child inside parent), setnested_layouts = trueunder[router]in.tibi-config, or give a single layoutinherit_layout = truein its frontmatter.- Pages can pick a named layout per-request by setting
result.layout = "name"in frontmatter. The router resolveslayouts/name.tibi(walking up from the page file) and wraps the page in it, ignoring the file-based_layout.tibi. Use this for conditional chrome — e.g.result.layout = ctx.user ? 'app' : 'marketing'. Set the value to""to opt out entirely and render bare even if a_layoutwould otherwise apply. - Layout resolution order, highest priority first: (1)
result.layoutfrom frontmatter, (2) nearest ancestor_layout.tibi, (3)layouts/default.tibiwalking up from the page file. Drop a singlelayouts/default.tibiin your project and every otherwise-layoutless page picks it up automatically. _404/_500never appear in the URL table — they're picked when nothing else matches / a render errors.- Folder named
componentsis excluded from the route walk sopages/blog/components/Sidebar.tibistays importable but doesn't get a URL.
Per-request route context
When tibi serve handles a request, it builds a RouteCtx Tibi threads through every render layer:
The same shape lands in three places so authors can pick whichever surface fits the task:
1 — Frontmatter (ctx.route)
Every frontmatter language sees the route under ctx.route. Encoded as JSON via the existing TIBI_CTX env var, so JS / TS / Python / Rust each read the same shape without per-language glue.
At build time (tibi build), ctx.route is None — SSG can't know dynamic params in advance. Read it defensively if a page can render in both modes.
2 — Auto-injected template tokens
The compiler auto-merges these into the template substitution dict so authors can write them as {token} directly with no frontmatter glue:
/docs/routing— the request path./docs/routing— thetibi-matchpattern (e.g./blog/{slug}).https://tibi.fi/docs/routing— the full request URL.https://tibi.fi— scheme + host only (e.g.https://tibi.fi). Useful for emitting copy-pasteable commands that hit the same box the visitor is on (e.g.curl https://tibi.fi/install.sh | sh). The scheme honours the proxy-standardX-Forwarded-Protoheader, so deploys behind a TLS-terminating proxy (Cloudflare, Traefik, Coolify) renderhttps://rather than the plain-http scheme the container itself sees.{param_name}— one entry per matched path param.{query_name}— one entry per query string key (last write wins on repeats).
Frontmatter-set values win on a name clash. So result.path = 'CUSTOM' in your frontmatter overrides the auto-injected /docs/routing.
2½ — Auto-SEO from frontmatter
Five well-known keys auto-emit <head> tags so every page gets per-route metadata without touching the layout:
title→<title>+og:title+twitter:titledescription→<meta name="description">+og:description+twitter:descriptioncanonical→<link rel="canonical">+og:urlimage→og:image+twitter:imagetheme_color→<meta name="theme-color">
Synthesised tags land in the page's <head> block, which the renderer appends AFTER the layout's <head>. Because browsers honour last-wins on duplicate <title> / <meta> tags, page-level SEO deterministically overrides whatever defaults the layout declared.
Keys stay available for normal Routing - Tibi template substitution too — useful for echoing the title inside the page body.
3 — Reactive client-side $route
Inside <script tibi>, the runtime exposes $route as a reactive proxy. Effects that read any field re-fire on every navigation (browser back / forward, history.pushState / replaceState, hashchange). The subscription auto-unsubscribes when the component unmounts.
Template directives consume the same proxy:
Imperative API: window.Tibi.route + $router
The reactive proxy is a thin wrapper over window.Tibi.route. Use it directly for non-reactive code (event handlers, plain JS, third-party integration). Inside <script tibi> the same surface is reachable via $router, which mirrors common framework verbs (Vue / Next / Nuxt / React Router / SvelteKit) so existing patterns paste in unchanged.
Tibi.route.snapshot()— current frozen snapshot. Same shape as the server'sRouteCtx.Tibi.route.subscribe(fn)— invokefn(snapshot)on every navigation. Returns an unsubscribe.Tibi.route.push(url, overrides?)—history.pushState+ notify. Optionaloverridessetroute_pattern/paramswhen you have them.Tibi.route.replace(url, overrides?)— same, viareplaceState.$router.push/.replace/.navigate/.goto/.back/.forward/.go— verb aliases on the imperative handle.navigate+gotoboth delegate topush.Tibi.route.path/.route_pattern/.params/.query/.hash/.url/.origin— getter properties returning fresh values.
Field aliases (other-framework muscle memory)
Both $route and $router (and Tibi.route directly) expose the same payload under several alias names so you don't have to relearn anything:
pathname→path(Next / React Router /window.location)fullPath→url(Vue Router / Nuxt)asPath→url(Next pages router)pattern/routeId→route_pattern(SvelteKit-style)search→"?k=v&…"string (Web Platform / React Router)searchParams→URLSearchParams(Web Platform / Next App Router)
Tracking nav from anywhere
The runtime monkey-patches History.prototype.pushState and replaceState so any code that mutates history — Tibi.route.push, third-party routers, hand-rolled history.* calls — drives subscribers. Pop / back / forward is covered by the standard popstate event; hash changes by hashchange.
Client doesn't know route_pattern
After a client-side navigation, $route.path / .query / .hash reflect location, but route_pattern + params are empty (tibi-match lives on the server). Pass them via the optional second arg if you want them populated:
Redirects + global aliases
Per-route redirects live in .tibi-config and fire ahead of the file-based router:
Useful for sunsetting old URLs without keeping the old .tibi file around.
Custom Tower stack (escape hatch)
The no-main.rs path (tibi serve) covers the common case. Apps that want extra middleware drop down to the underlying surface:
render_route_with_ctx + RouteCtx are re-exported from both tibi and tibi_router; the no-ctx render_route still works for SSG / build-time use.
Client-side navigation
With client = true, clicking a same-origin link fetches the next page and swaps only what sits inside the deepest layout the two pages share. Everything in those layouts stays on screen untouched: component state, typed input, open menus, scroll positions inside panels, websocket subscriptions. An editor with tabs under pages/edit/_layout.tibi keeps its draft while the tab content changes. The swap runs inside document.startViewTransition where supported, so the CSS in the next section animates it.
- The server marks each layout's slot with
<!--tibi-outlet:ID-->comments; they don't affect CSS or selectors. - The page's
<title>, description, canonical / Open Graph tags and page stylesheet are updated. Scripts of the incoming page run once; class definitions and the runtime are not re-run. Tibi.route/$routeupdate with the new path, pattern and params. Back and forward re-fetch and restore the scroll position.- It falls back to a normal page load when the pages share no layout, the next page needs client modules not in the current import map, the response isn't HTML, or anything fails.
- Skipped for modified clicks,
targetlinks,download,/api/and/__tibi/paths, and links withdata-tibi-reload. A page can force full loads with<meta name="tibi-reload">. - Navigate from code with
Tibi.nav.go(url)or$router.navigate(url).tibi:navigateandtibi:navigatedevents fire onwindow.
View Transitions
The View Transitions API ships in Chromium-based browsers and Safari. Tibi cooperates in two ways, both pure-CSS to opt into:
- Cross-document (regular link navigation): the browser handles it natively. Add
@view-transition { navigation: auto; }to your global stylesheet and same-origin navigations cross-fade by default. Customise per-element viaview-transition-name: foo+::view-transition-old(foo)/::view-transition-new(foo)animations. - Same-document (dev HMR soft-reload): the runtime wraps every body / head swap in
document.startViewTransition, so the same CSS rules apply when an edit triggers a hot-reload. Authors see the transition exactly as visitors will. Browsers without the API fall back to the immediate swap.
No flag, no plugin, no JS wiring required from the author. Pure CSS contract.