Rally
Rally is a Gleam package for building Lustre apps that render on the server and hydrate in the browser. You write page modules and server_* handler functions. Rally generates routing, server-side rendering, WebSocket transport, and typed client-server messaging.
The page file is the contract. Client state, server calls, and the message types that cross the wire all live together until you choose to extract shared code.
Rally apps use SQLite by default: embedded database, migrations, and type-safe SQL codegen, with no separate database server for development.
What Rally Generates
Rally reads page modules and writes the routing, SSR, WebSocket transport, request and response encoding, and dispatch code around them.
You still write the UI, SQL, auth policy, and server handlers.
Create an app
gleam new my_app
cd my_app
gleam add rally libero
gleam run -m rally init
./bin/dev
rally init writes the starter app into the current Gleam project, including bin/dev. After that, ./bin/dev runs codegen, builds the JS client, and starts the server on port 8080. Open http://localhost:8080 to see the app. The starter app uses SQLite, so development does not need a database daemon.
Writing a page
A page file in src/<namespace>/pages/ is a Lustre component with server calls:
import gleam/int
import lustre/element.{type Element, text}
import lustre/element/html
import rally_runtime/effect.{type Effect}
import rally_runtime/effect
import server_context.{type ServerContext}
// Client state for this page.
pub type Model {
Model(count: Int)
}
// init creates the first client model for this page.
pub fn init() -> #(Model, Effect(Msg)) {
#(Model(count: 0), effect.none())
}
// Client messages handled by update.
pub type Msg {
Increment
GotIncrement(Result(Int, List(String)))
}
// Message sent from the client to the server.
pub type ServerIncrement {
ServerIncrement(amount: Int)
}
// This pub fn server_increment is the server handler.
// It runs on the server and receives the ServerIncrement message.
// Libero uses its signature as the wire contract.
pub fn server_increment(
msg msg: ServerIncrement,
server_context _server_context: ServerContext,
) -> Result(Int, List(String)) {
Ok(msg.amount)
}
// update is part of the shared page contract.
// Client events call it in the browser after hydration.
pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
case msg {
Increment ->
// effect.rpc sends ServerIncrement to server_increment above.
#(model, effect.rpc(ServerIncrement(amount: 1), on_response: GotIncrement))
GotIncrement(Ok(amount)) ->
#(Model(count: model.count + amount), effect.none())
GotIncrement(Error(_)) ->
#(model, effect.none())
}
}
// view is shared by server (SSR) and client (SPA).
pub fn view(model: Model) -> Element(Msg) {
html.button([], [text("Count: " <> int.to_string(model.count))])
}
Model, Msg, init, update, and view are normal Lustre TEA. ServerIncrement and server_increment define the server call. The client sends the typed message with effect.rpc.
There is no separate API schema. Libero scans the handler signature and Rally wires it into the generated client and server code.
File-based routing
The filename determines the URL:
| File | URL | Route variant |
|---|---|---|
home_.gleam or index.gleam | / | Home |
about.gleam | /about | About |
products/id_.gleam | /products/:id | ProductsId(id: Int) |
settings/profile.gleam | /settings/profile | SettingsProfile |
A trailing _ makes the segment dynamic. Params named id or ending in _id parse as Int; others parse as String.
What to import
Most Rally apps use only a few modules directly:
| Module | Use it for |
|---|---|
rally_runtime/effect | Page effects: RPC, server messages, navigation, broadcast, client context updates |
rally_runtime/db | SQLite open, timed queries, nested transactions, SQL value helpers |
rally_runtime/system | App startup, message logging, background jobs |
rally_runtime/session | Session cookie generation, parsing, response headers |
rally_runtime/auth | Auth policy and load result types |
rally_runtime/env | APP_ENV parsing and production cookie policy |
rally_runtime/migrate | Numbered SQLite migrations |
rally_runtime/test_db | Fast in-memory SQLite for tests |
The rally/internal/... modules are codegen implementation. App code should treat them as private. The generated files under src/generated/ are the boundary Rally presents to your app.
Generated files
Running gleam run -m rally reads [[tools.rally.clients]] from gleam.toml and produces:
Server-side (in src/generated/<namespace>/): router, page dispatch, RPC dispatch, SSR handler, WebSocket handler, HTTP handler, protocol wire facade.
Client-side (in .generated_clients/<namespace>/): Lustre SPA entry, WebSocket transport, tree-shaken page modules, codec, effect shim.
The client package is a standalone Gleam project. The server project is the input to codegen.
Examples
examples/realworld/: RealWorld (Conduit) clone with auth, articles, comments, tags, favorites, follows. Uses both RPC and stateful server models. See its README.
More docs
- Pages: routing, page lifecycle, SSR loading, and layouts
- Server messaging: RPC, stateful server pages, and broadcast
- Runtime: the
rally_runtime/*modules app code imports - Configuration:
gleam.toml, generated paths, and protocols - Comparisons: Rally, Lustre server components, and Lamdera-style apps
- Internals: codegen pipeline and contributor reading order
- llms.txt: raw context for language models
Contributing
Rally is a Gleam project targeting Erlang. You need Gleam (v1.x), Erlang/OTP 26+, SQLite3, and Node.js.
git clone <repo-url>
cd rally
gleam build
gleam test
Rally depends on Libero. App projects should add both packages with gleam add rally libero.
Influences
- Lamdera: explicit server handler types as the contract, TEA on both sides
- Lustre: TEA, effects, and the client-side UI runtime
- elm-land: file-based routing conventions
- Libero: wire protocol and RPC contract layer
- Marmot: SQL-first codegen with live SQLite introspection
License
MIT. See LICENSE.