Rust on the server. Real HTML in the browser.
Rahti is a server-rendered web framework for Rust. Pages are files, components are ordinary functions, server calls are attributes — and reactivity arrives exactly where you asked for it. No Node, no bundler, no JavaScript build step.
cargo install cargo-rahti
cargo rahti new my-app --tailwind
cd my-app && cargo run// src/app/customers/[id]/page.rs → /customers/{id}
use axum::extract::Path;
use crate::rahti::{Html, html, rpc};
pub async fn page(Path(id): Path<String>) -> Html {
let customer = load(&id).await;
html! {
<article class="card">
<h1>@{customer.name}</h1>
<p>"Seats in use: "{seats}</p>
<button onclick={buy()}>"Add a seat"</button>
<script>
const [seats, setSeats] = pp.state(@{customer.seats});
async function buy() {
setSeats(await pp.rpc("add_seat", { id: @{id} }));
}
</script>
</article>
}
}
#[rpc]
pub async fn add_seat(id: String) -> rahti::Result<u32> {
Ok(billing::add_seat(&id).await?)
}One file: a route, a rendered page, browser state, and a server function the browser can call.
0
npm packages, bundlers or build steps
1
binary to deploy, beside public/
2
expression dialects to learn
8
convention documents in every project
The dividing line
Two dialects, one file
Every framework has to decide where the server stops and the browser starts. Rahti decides it in the syntax, so you can always see it.
@{expression}
Rust, evaluated on the server while the page renders. The value is escaped on its way into the document, so a name with an angle bracket in it is a name, not markup.
{expression}
JavaScript, evaluated in the browser by PulsePoint whenever the state it reads changes. A React-shaped hook surface — state, effects, memo, context, reducers, transitions — with nothing to install.
They never interchange, and each side is handled by the machinery that understands it. What the browser receives is the page, already rendered, with a name on the parts the runtime owns:
<!-- What the browser is handed: markup, and a name for the runtime. -->
<section class="card" pp-component="page_9f31c0a4">
<h1>Ada Lovelace</h1>
<p>Seats in use: <!--pp-->3<!--/pp--></p>
<button>Add a seat</button>
</section>In the box
A whole application, not a rendering library
Routing, rendering, reactivity, server calls, sockets, sessions and a database story — each with one way to do it, and a build error when you do it another way.
File-system routing
Typed HTML
html! is parsed at compile time, so a stray tag is a compiler error rather than a broken page. Interpolated values are escaped by default.Components as functions
#[component], called as <Card />. Props are the function's arguments, so rustc checks the call site like any other call.Typed RPC
#[rpc] beside its caller. CSRF protection by default, typed arguments and returns, error propagation through rahti::Result, server streaming, and uploads with progress.PulsePoint reactivity
public/js/, not as a build step.WebSockets
#[socket] functions beside their pages, arguments as the connection's first frame, a same-origin handshake check, and cloneable senders for broadcast.Authentication
Database, when you want one
Diagnostics you can read
.rahti/dev.log as JSON Lines — a file a person, or an agent, can grep.Components
A function you can call, or a tag you can write
There is no props struct to keep in sync and no component registry to update. Save a file under src/components/ and the component is in scope, as a tag, everywhere.
- Props are typed arguments — a missing one is "missing field", not a runtime warning.
- Children arrive as an ordinary Html argument and render with <slot />.
- Each rendered instance is its own reactive scope, so two counters count independently.
- An external component library is a normal Cargo dependency, CSS included.
// src/components/card.rs — in scope in every page, as a tag.
use crate::rahti::{Html, component, html};
#[component]
pub fn Card(title: &str, children: Html) -> Html {
html! {
<section class="card">
<h2>@{title}</h2>
<slot />
</section>
}
}<Card title="Profile">
<p>"Ada"</p>
<Badge text="Rust" />
</Card>Built to be handed over
Written for coding agents, too
Rahti is not in any model's training data. So every project it scaffolds carries the framework's rules with it, and stays current as the framework moves.
The rules ship with the project
An AGENTS.md guide, a CLAUDE.md pointing at it, and convention documents under docs/conventions/ covering routing, rendering, reactivity, RPC, auth and security.
Mistakes are loud
There is one way to do each thing. Break a rule and the build says which rule, in a message written to be read.
Upgrades leave your work alone
cargo rahti upgrade refreshes scaffold files whose recorded hashes show you never edited them, and leaves everything you touched alone.
Deployment is a binary and a directory
Compile the application, copy it next to public/, and run it. There is no separate front-end to build, host, or keep in step.
