Below is the architecture I’d use for a multiplayer todo app with Leptos + Datastar + Axum SSE.
The important design choice: Leptos renders typed server-side HTML components. Datastar owns browser events and DOM patching. Axum owns routes, mutations, and SSE streams. Do not hydrate the same DOM subtree with Leptos WASM and then also morph it with Datastar.
Datastar’s model fits this well because it patches HTML elements from the backend using SSE and morphing, so the backend can stay the source of truth. (Datastar) The Rust SDK has Axum integration and PatchElements/PatchSignals primitives. (Docs.rs) Leptos supports SSR, and its Axum integration exists for server-side apps, but in this guide we are using Leptos mostly as a typed HTML renderer rather than a hydrated client framework. (Leptos Book)
Build a todo app where multiple browser tabs/users see the same todo list update in real time.
When Alice adds a todo, Bob sees it appear without refreshing. When Bob completes it, Alice sees it update.
The flow is:
Browser opens page
-> Datastar starts /todos/stream SSE connection
User submits mutation
-> POST /todos
-> server mutates shared state
-> server broadcasts "todos changed"
Every connected SSE stream receives event
-> server re-renders TodoApp with Leptos SSR
-> Datastar morphs #todo-app in every browser
Use Leptos for:
typed components
component go-to-definition
typed props
server-side rendering to HTML strings
Use Datastar for:
data-on-click
data-on-submit
@post(...)
@get(...)
SSE patching
small client signals only when useful
Use Axum for:
HTTP routes
form parsing
shared app state
broadcast fanout
SSE responses
Avoid:
Leptos hydration + Datastar morphing the same component
That creates two owners of the DOM.
multiplayer-todo/
Cargo.toml
src/
main.rs
model.rs
views.rs
routes.rs
You can start single-file, but a real app gets clearer if you split model, views, and routes.
[package]
name = "multiplayer-todo"
version = "0.1.0"
edition = "2021"
[dependencies]
async-stream = "0.3"
axum = { version = "0.8", features = ["form"] }
datastar = { version = "0.3", features = ["axum"] }
leptos = { version = "0.8", features = ["ssr"] }
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["full"] }
uuid = { version = "1", features = ["v4", "serde"] }For a production version, replace the in-memory store with Postgres.
Start with a simple in-memory store:
use std::collections::BTreeMap;
use uuid::Uuid;
#[derive(Clone, Debug)]
pub struct Todo {
pub id: Uuid,
pub title: String,
pub done: bool,
pub revision: u64,
}
#[derive(Default)]
pub struct TodoStore {
pub todos: BTreeMap<Uuid, Todo>,
pub revision: u64,
}
impl TodoStore {
pub fn list(&self) -> Vec<Todo> {
self.todos.values().cloned().collect()
}
pub fn create(&mut self, title: String) {
self.revision += 1;
let id = Uuid::new_v4();
self.todos.insert(
id,
Todo {
id,
title,
done: false,
revision: self.revision,
},
);
}
pub fn toggle(&mut self, id: Uuid) {
if let Some(todo) = self.todos.get_mut(&id) {
self.revision += 1;
todo.done = !todo.done;
todo.revision = self.revision;
}
}
pub fn delete(&mut self, id: Uuid) {
if self.todos.remove(&id).is_some() {
self.revision += 1;
}
}
}For multiplayer, the key concept is the global revision. Every mutation increments it. Later, this lets you add conflict checks, optimistic UI, reconnect behavior, and “missed event” recovery.
use std::sync::{Arc, Mutex};
use tokio::sync::broadcast;
#[derive(Clone)]
pub struct AppState {
pub store: Arc<Mutex<TodoStore>>,
pub changed: broadcast::Sender<u64>,
}
impl AppState {
pub fn snapshot(&self) -> (u64, Vec<Todo>) {
let store = self.store.lock().unwrap();
(store.revision, store.list())
}
pub fn notify_changed(&self) {
let revision = {
let store = self.store.lock().unwrap();
store.revision
};
let _ = self.changed.send(revision);
}
}The broadcast::Sender is the multiplayer fanout mechanism. Each connected browser gets its own receiver.
The page shell can be a Leptos component too. That keeps everything Rust-native and navigable.
use leptos::prelude::*;
pub fn render_page(revision: u64, todos: Vec<Todo>) -> String {
leptos::ssr::render_to_string(move || {
view! {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>"Multiplayer Todo"</title>
<script
type="module"
src="https://cdn.jsdelivr.net/gh/starfederation/datastar@main/bundles/datastar.js"
></script>
<style>{APP_CSS}</style>
</head>
<body>
<TodoApp revision=revision todos=todos />
</body>
</html>
}
})
.to_string()
}
pub fn render_todo_app(revision: u64, todos: Vec<Todo>) -> String {
leptos::ssr::render_to_string(move || {
view! {
<TodoApp revision=revision todos=todos />
}
})
.to_string()
}Then the main component:
#[component]
pub fn TodoApp(revision: u64, todos: Vec<Todo>) -> impl IntoView {
let remaining = todos.iter().filter(|todo| !todo.done).count();
view! {
<main
id="todo-app"
class="shell"
data-revision=revision
data-init="@get('/todos/stream')"
>
<header class="header">
<div>
<h1>"Multiplayer Todo"</h1>
<p>"Everyone sees the same backend-owned list."</p>
</div>
<span class="revision">
"rev " {revision}
</span>
</header>
<form
class="new-todo"
data-on-submit="
evt.preventDefault();
@post('/todos', {contentType: 'form'});
evt.target.reset();
"
>
<input
name="title"
autocomplete="off"
placeholder="Add a todo..."
/>
<button type="submit">
"Add"
</button>
</form>
<TodoList todos=todos />
<footer class="footer">
{remaining} " remaining"
</footer>
</main>
}
}A todo list:
#[component]
pub fn TodoList(todos: Vec<Todo>) -> impl IntoView {
if todos.is_empty() {
return view! {
<section id="todo-list" class="empty">
"No todos yet."
</section>
}
.into_any();
}
view! {
<ul id="todo-list" class="todos">
{todos
.into_iter()
.map(|todo| view! { <TodoRow todo=todo /> })
.collect_view()}
</ul>
}
.into_any()
}A row:
#[component]
pub fn TodoRow(todo: Todo) -> impl IntoView {
let class = if todo.done { "todo done" } else { "todo" };
let toggle_label = if todo.done { "Undo" } else { "Done" };
view! {
<li id=format!("todo-{}", todo.id) class=class>
<span class="title">
{todo.title}
</span>
<div class="actions">
<button
type="button"
data-on-click=format!("@post('/todos/{}/toggle')", todo.id)
>
{toggle_label}
</button>
<button
type="button"
data-on-click=format!("@post('/todos/{}/delete')", todo.id)
>
"Delete"
</button>
</div>
</li>
}
}The key Datastar idea is that the row does not maintain private client state. The button posts to the backend. The backend mutates state. The shared SSE stream patches every client.
You want these routes:
GET /
GET /todos/stream
POST /todos
POST /todos/:id/toggle
POST /todos/:id/delete
GET / returns the initial HTML page.
GET /todos/stream is long-lived. It patches #todo-app whenever the store changes.
Mutation routes return 204 No Content. They do not need to return HTML because the stream will patch all clients.
use axum::{
routing::{get, post},
Router,
};
use std::sync::{Arc, Mutex};
use tokio::sync::broadcast;
#[tokio::main]
async fn main() {
let (changed, _) = broadcast::channel(256);
let state = AppState {
store: Arc::new(Mutex::new(TodoStore::default())),
changed,
};
let app = Router::new()
.route("/", get(index))
.route("/todos/stream", get(todo_stream))
.route("/todos", post(create_todo))
.route("/todos/{id}/toggle", post(toggle_todo))
.route("/todos/{id}/delete", post(delete_todo))
.with_state(state);
let listener = tokio::net::TcpListener::bind("127.0.0.1:3000")
.await
.unwrap();
axum::serve(listener, app).await.unwrap();
}use axum::{extract::State, response::Html};
async fn index(State(state): State<AppState>) -> Html<String> {
let (revision, todos) = state.snapshot();
Html(render_page(revision, todos))
}use axum::{extract::State, http::StatusCode, Form};
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct CreateTodoForm {
title: String,
}
async fn create_todo(
State(state): State<AppState>,
Form(form): Form<CreateTodoForm>,
) -> StatusCode {
let title = form.title.trim();
if title.is_empty() {
return StatusCode::NO_CONTENT;
}
{
let mut store = state.store.lock().unwrap();
store.create(title.to_string());
}
state.notify_changed();
StatusCode::NO_CONTENT
}This is deliberately boring. The backend mutates state and broadcasts. The SSE connection handles rendering.
use axum::extract::Path;
use uuid::Uuid;
async fn toggle_todo(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> StatusCode {
{
let mut store = state.store.lock().unwrap();
store.toggle(id);
}
state.notify_changed();
StatusCode::NO_CONTENT
}async fn delete_todo(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> StatusCode {
{
let mut store = state.store.lock().unwrap();
store.delete(id);
}
state.notify_changed();
StatusCode::NO_CONTENT
}This is the most important part.
use async_stream::stream;
use axum::{extract::State, response::IntoResponse};
use datastar::prelude::*;
async fn todo_stream(State(state): State<AppState>) -> impl IntoResponse {
let mut rx = state.changed.subscribe();
datastar::axum::Sse(stream! {
// Initial patch when the stream connects.
let (revision, todos) = state.snapshot();
yield PatchElements::new(render_todo_app(revision, todos)).into();
loop {
match rx.recv().await {
Ok(_revision) => {
let (revision, todos) = state.snapshot();
// Fat morph the whole component.
yield PatchElements::new(render_todo_app(revision, todos)).into();
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
// Client missed one or more broadcasts.
// Re-send the full current component.
let (revision, todos) = state.snapshot();
yield PatchElements::new(render_todo_app(revision, todos)).into();
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
break;
}
}
}
})
}This is the “multiplayer” part. Every connected browser has a receiver. One mutation causes one broadcast. Every stream re-renders from the latest backend state.
For this app, patching the whole #todo-app is simpler and safer than sending targeted row patches.
Good starting rule:
One state change -> re-render the whole logical component -> Datastar morphs it
Datastar’s morphing behavior is specifically meant to update changed parts while preserving useful DOM state. (Datastar)
Later, you can optimize:
append one todo row
remove one todo row
patch only footer count
patch only one row
But for an admin-style SaaS app, fat morphs are usually the right first implementation.
For todos, last-write-wins is fine.
But multiplayer apps need an explicit stance. Start with:
create: always succeeds
toggle: last write wins
delete: idempotent
stream lag: resend full current state
reconnect: initial stream patch sends full current state
Then add revision checks only where needed.
For example, if you add inline editing:
POST /todos/:id/rename
fields:
title
expected_revision
If the todo’s current revision does not match expected_revision, return a patch that re-renders the row with an error:
"This todo changed in another tab. Review and try again."
For a simple toggle/delete app, do not overbuild this.
Once the in-memory version works, move the store to Postgres.
Suggested tables:
CREATE TABLE todo (
id UUID PRIMARY KEY,
title TEXT NOT NULL,
done BOOLEAN NOT NULL DEFAULT false,
revision BIGINT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE app_revision (
id BOOLEAN PRIMARY KEY DEFAULT true,
revision BIGINT NOT NULL
);
INSERT INTO app_revision (id, revision)
VALUES (true, 0);Mutation transaction shape:
BEGIN
increment app_revision
insert/update/delete todo using new revision
COMMIT
broadcast new revision
In Rust, your mutation route becomes:
run SQL transaction
commit
broadcast revision
return 204
Do not broadcast before commit.
tokio::sync::broadcast only works inside one server process.
That is fine for local dev and one-instance deployment. For multiple server instances, use a cross-process pub/sub layer:
Postgres LISTEN/NOTIFY
Redis pub/sub
NATS
Kafka
For your use case, Postgres LISTEN/NOTIFY is probably the simplest first production step.
The mutation flow becomes:
HTTP mutation
-> SQL transaction commits
-> NOTIFY todos_changed, revision
Each app instance:
-> LISTEN todos_changed
-> local broadcast to connected SSE clients
SSE can disconnect. Your app should tolerate this.
Datastar’s data-init="@get('/todos/stream')" reconnect behavior depends on the browser/runtime request lifecycle, but your server-side design should assume clients may reconnect at any time.
The stream endpoint should always do this first:
let (revision, todos) = state.snapshot();
yield PatchElements::new(render_todo_app(revision, todos)).into();That makes reconnect safe.
Keep durable state on the backend:
todos
done flags
current filter if shared
sort order if shared
assignee
presence
revision
Keep ephemeral state in the browser:
input field value
open dropdown
hover state
temporary loading state
Use Datastar signals for ephemeral state only when they help. Do not mirror the full todo list into Datastar signals. In this architecture, the todo list is backend-owned HTML.
After basic multiplayer works, add presence.
Simplest version:
GET /presence/stream
POST /presence/heartbeat
But a cleaner Datastar/SSE version is to associate each stream connection with a client_id.
On stream connect:
mark client online
broadcast presence changed
On stream drop:
mark client offline
broadcast presence changed
Then render:
#[component]
fn Presence(users_online: usize) -> impl IntoView {
view! {
<span class="presence">
{users_online} " online"
</span>
}
}You can include presence in the same TodoApp fat morph or patch a smaller #presence element separately.
Build it in this order:
1. Static Leptos SSR page
2. Render todos from in-memory state
3. Add POST /todos
4. Add GET /todos/stream
5. After create, broadcast and patch all clients
6. Add toggle/delete
7. Open two browser windows and verify both update
8. Add revision display
9. Add Postgres persistence
10. Replace in-process broadcast with Postgres LISTEN/NOTIFY if deploying multiple instances
The app is not “Leptos app with Datastar sprinkled in.”
It is:
Axum app
owns HTTP, mutations, and SSE
Leptos SSR
owns typed HTML rendering
Datastar
owns browser events and DOM patch application
That gives you the closest Rust version of your templ + Datastar taste:
typed server-rendered components
go-to-definition on components and structs
backend-owned state
HTML over the wire
minimal frontend build complexity