DocsBuilding pages
Routing
Directories under src/app are URL segments, and a handful of known filenames give a segment its role. Nothing registers a route: the build step reads the tree and regenerates the router.
The files a segment can hold
Only these filenames participate. Everything else in a route directory is ordinary Rust that the router ignores.
| File | Required export | Behaviour |
|---|---|---|
| page.rs | pub async fn page(…) | A server-rendered page. |
| layout.rs | pub fn layout(children: Html) -> Html | Wraps every descendant page. |
| error.rs | pub fn error(error: ErrorInfo) -> Html | The nearest failure boundary below it. |
| loading.rs | pub fn loading() -> Html | Markup shown while a navigation is in flight. |
| not-found.rs | pub async fn not_found() -> Html | The root 404 body. Only at the app root. |
| route.rs | get, post, put, patch, delete, head, options | An unwrapped API endpoint. |
Segment syntax
| Directory | URL behaviour |
|---|---|
| users | The literal path /users. |
| [id] | A dynamic segment, /{id}. |
| [...slug] | A required catch-all, /{*slug}. |
| [[...slug]] | An optional catch-all: the base URL and /{*slug}. |
| (marketing) | A route group. Adds no URL segment; still inherits layouts. |
| _lib | Private. Ignored by the router, recursively. |
src/app/page.rs -> /
src/app/customers/[id]/page.rs -> /customers/{id}
src/app/posts/[...id]/page.rs -> /posts/{*id}
src/app/docs/[[...slug]]/page.rs -> /docs and /docs/{*slug}
src/app/(marketing)/adds/page.rs -> /adds
src/app/_lib/helpers.rs -> not routed at allAn optional catch-all page is registered for both shapes, so a handler that serves both cannot require an extractor that is absent on the base URL. Take it as Option<Path<String>> and branch.
Pages and extractors
A page may return Html, any compatible Axum response, or rahti::Result when it can fail. Its parameters are ordinary Axum extractors, so a dynamic segment arrives the way it does anywhere else in Axum.
use axum::extract::Path;
use crate::rahti::{Html, Result, html};
pub async fn page(Path(id): Path<String>) -> Result {
let customer = load(&id).await?;
Ok(html! {
<h1>@{customer.name}</h1>
})
}API routes
A route.rs segment answers with data rather than a document. API handlers inherit no layouts and no page error boundaries — what they return is what the client gets.
// src/app/api/messages/route.rs → GET and POST /api/messages
use axum::{Json, http::StatusCode};
use serde::{Deserialize, Serialize};
#[derive(Serialize)]
pub struct Message {
id: u64,
text: String,
}
#[derive(Deserialize)]
pub struct CreateMessage {
text: String,
}
pub async fn get() -> Json<Vec<Message>> {
Json(vec![Message {
id: 1,
text: "Hello from route.rs".to_string(),
}])
}
pub async fn post(Json(input): Json<CreateMessage>) -> (StatusCode, Json<Message>) {
(
StatusCode::CREATED,
Json(Message {
id: 2,
text: input.text,
}),
)
}curl http://127.0.0.1:3000/api/messages
curl -X POST http://127.0.0.1:3000/api/messages \
-H "content-type: application/json" \
-d "{\"text\":\"Hello Rahti\"}"Query strings
A query string is not part of routing. Segments select the handler; ?a=1&b=2 reaches it as request data, and no directory name corresponds to it. A page reads it with Query, exactly as it reads a dynamic segment with Path.
use axum::extract::{Query, RawQuery};
use serde::Deserialize;
use crate::rahti::{Html, html};
#[derive(Deserialize)]
pub struct Params {
id: Option<u64>,
name: Option<String>,
}
// A page may take several extractors — each one reads the same request.
pub async fn page(
Query(params): Query<Params>,
Query(pairs): Query<Vec<(String, String)>>,
RawQuery(raw): RawQuery,
) -> Html {
// `params` is the typed view, `pairs` every pair, `raw` the unparsed text.
html! { <p>@{format!("{:?}", params.id)}</p> }
}| Target type | What it reads |
|---|---|
a Deserialize struct | Named parameters, typed. |
| Vec<(String, String)> | Every pair in URL order, repeats preserved. |
| HashMap<String, String> | Every name once — a repeated name keeps the last. |
| RawQuery | The unparsed string, or None when there is no ?. |
Two cases produce one: a field the query string does not supply at all, unless the field is Option; and a value that does not parse into the field's type — where Option does not help. Option<u64> tolerates a missing id; it still rejects ?id=abc. So Option states that a parameter is optional, and nothing more.
When a bad value deserves a rendered page instead of a bare 400, take the parameter as a String, parse it in the body, and return rahti::Result so ? reaches the boundary:
use axum::extract::Query;
use axum::http::StatusCode;
use serde::Deserialize;
use crate::rahti::{Error, Result, html};
#[derive(Deserialize)]
pub struct Params {
id: String,
}
pub async fn page(Query(params): Query<Params>) -> Result {
let id: u64 = params.id.parse().map_err(|_| {
Error::new(StatusCode::BAD_REQUEST, format!("`{}` is not an id", params.id))
})?;
Ok(html! { <p>@{id}</p> })
}Layouts
Layouts inherit by directory ancestry, route groups included. The descendant page renders first, then its result climbs from the deepest layout to the root. <slot /> is where the children land.
// src/app/dashboard/layout.rs
pub fn layout(children: Html) -> Html {
html! {
<div class="dashboard">
<aside>"Navigation"</aside>
<main>
<slot />
</main>
</div>
}
}Only responses marked as Rahti page bodies are wrapped. JSON, redirects, static assets, API routes and RPC responses pass through untouched.
Root layouts
The outermost layout applying to a page is its root layout — the one that renders the doctype, <html>, <head> and <body>. With only src/app/layout.rs present, every page shares it.
An application may have more than one. Two shapes are supported:
- No
src/app/layout.rs, and alayout.rsin each top-level route group. Each group's outermost layout is that group's root. src/app/layout.rspresent, and a deeper layout that declares itself a document root. The declaration ends layout inheritance at that file:
// A deeper layout that owns its own document. The marker must be written on
// a line of its own, exactly like this — the build text-scans for it.
pub const ROOT_LAYOUT: bool = true;
pub fn layout(children: Html) -> Html {
html! {
<!DOCTYPE html>
<html lang="en">
<head>
<link href="/css/admin.css" rel="stylesheet" />
</head>
<body>
<slot />
</body>
</html>
}
}Each root layout has an identity — the name the build derives from its path — and Rahti sends it on every page that layout wraps, both as the X-PP-Root-Layout header and as a <meta name="pp-root-layout"> tag. That is what makes a navigation between two shells a full browser load rather than a body swap; see the PulsePoint runtime.
Error boundaries
A page that returns Err(Error) or panics is converted into a response the nearest ancestor error.rs renders. The boundary receives the numeric status, the canonical status text, and the application or panic detail.
// src/app/customers/error.rs — the nearest boundary below this directory.
use crate::rahti::{ErrorInfo, Html, html};
pub fn error(error: ErrorInfo) -> Html {
html! {
<section class="error">
<h1>@{error.code()}" "@{error.reason()}</h1>
<p>@{error.message()}</p>
</section>
}
}return Err(Error::new(StatusCode::BAD_REQUEST, "explanation"));The error UI replaces the failed subtree: layouts below the selected boundary are skipped, and layouts above it still wrap the rendered error. A standard error crossing ? converts to an internal Rahti error. RPC failures do not pass through page boundaries — they stay JSON errors for pp.rpc(…) to handle.
Loading regions
A page is rendered on the server and arrives complete, so there is nothing to suspend on. The wait a loading.rs covers is the navigation — the moment between a link being clicked and the response coming back. Rahti renders every region once at startup and writes the results into every document, so a page never waits on one.
Two pieces are needed: the region, and the file that fills it.
// src/app/dashboard/layout.rs — the marker wraps only the content pane, so
// the header stays on screen while the swap is in flight.
pub fn layout(children: Html) -> Html {
html! {
<div>
<header>"Dashboard"</header>
<main>
<div pp-loading-content="true">
<slot />
</div>
</main>
</div>
}
}// src/app/dashboard/loading.rs — what shows there while the fetch is out.
use crate::rahti::{Html, html};
pub fn loading() -> Html {
html! {
<div class="spinner">"Loading…"</div>
}
}Which region shows
The browser chooses. At the start of a navigation it takes the path in the address bar and walks it up one segment at a time, taking the first region it finds — so /slow/report tries /slow/report, then /slow, then /.
The path in the address bar at that moment is the page being left, not the one being loaded: history is updated after the response arrives. A link from / to /slow therefore shows the root region, and a link from /slow to anywhere shows /slow's. A region belongs to the section a reader is navigating within.
Because the walk is over literal path segments, a loading.rs under a dynamic segment is a build error — /customers/{id} is a string no browser is ever on, so the file would ship and never match. Put it on a static ancestor, which covers the dynamic children anyway. Route groups are fine: they contribute no URL segment.
Transition timing
The runtime fades the content region out, swaps the markup in, and fades it back. Both halves default to 250ms. To change them, put pp-loading-transition on an element inside the loading markup, holding JSON with fadeIn and fadeOut.
// JSON is braces, and braces in an authored attribute are a PulsePoint
// expression — so the timing is handed over as a server value.
const TRANSITION: &str = r#"{"fadeIn":"120ms","fadeOut":"120ms"}"#;
pub fn loading() -> Html {
html! {
<div pp-loading-transition=@{TRANSITION}>"Loading…"</div>
}
}An application that defines at least one loading.rs opts out of the browser's View Transitions path: the runtime uses the loading region instead. An application with none writes no container and keeps view transitions.
Not found, and the static fallback
Explicit routes are matched first. Unclaimed paths fall through to the configured public directory. If no public file exists, the root not-found.rs is rendered with HTTP 404 and wrapped by the root layout. Nested not-found.rs files are not currently routed.
The generated router
The build step groups pages sharing a layout and error chain into Axum subrouters, then adds page RPC routes, component RPC fallback, upload body limits, the static fallback, CSRF, dev routes and revalidation. It also installs the auth guard as the outermost layer of every group and of the RPC router — but not over the static fallback, so public/ is served without a session.
