Build a Real-Time Todo App
Build a collaborative todo app where changes sync instantly across all connected clients. You will learn how to define models, write queries and mutations, enable real-time subscriptions, and wire everything up with type-safe frontend bindings.
Prerequisites
- Rust 1.92+ and Docker installed
- Bun (for SvelteKit) or dioxus-cli (for Dioxus)
Step 1: Create the Project
forge new my-todos --template with-svelte/realtime-todo-list
# or
forge new my-todos --template with-dioxus/realtime-todo-list
The generated project structure:
my-todos/
├── forge.toml # Forge configuration
├── docker-compose.yml # PostgreSQL + backend + frontend
├── migrations/
│ └── 0001_todos.sql # Database migration
├── src/
│ ├── main.rs # Entrypoint
│ ├── schema/
│ │ └── todo.rs # Data model
│ └── functions/
│ └── todos.rs # Queries and mutations
└── frontend/ # SvelteKit or Dioxus app
Step 2: Define the Data Model
In src/schema/todo.rs, define the Todo struct with the #[forge::model] attribute:
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[forge::model]
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct Todo {
pub id: Uuid,
pub title: String,
pub completed: bool,
pub created_at: DateTime<Utc>,
}
#[forge::model] registers the struct with Forge so it can generate type-safe frontend bindings. The derive macros enable serialization and direct mapping from SQL rows.
Step 3: Write the Migration
Create migrations/0001_todos.sql:
-- @up
CREATE TABLE todos (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT NOT NULL,
completed BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
SELECT forge_enable_reactivity('todos');
-- @down
SELECT forge_disable_reactivity('todos');
DROP TABLE IF EXISTS todos;
forge_enable_reactivity('todos') installs a PostgreSQL trigger on the todos table. Whenever a row is inserted, updated, or deleted, the trigger fires a NOTIFY event. Forge listens for these events and automatically re-runs any subscribed queries, pushing updates to connected clients over SSE.
Step 4: Create Queries
In src/functions/todos.rs, define a query to list all todos:
use forge::prelude::*;
use uuid::Uuid;
use crate::schema::Todo;
#[forge::query(public, tables = ["todos"])]
pub async fn list_todos(ctx: &QueryContext) -> Result<Vec<Todo>> {
sqlx::query_as!(Todo, "SELECT * FROM todos ORDER BY created_at DESC")
.fetch_all(ctx.db())
.await
.map_err(Into::into)
}
Key points:
public-- no authentication required. Remove this for authenticated endpoints.tables = ["todos"]-- tells Forge which tables this query reads from. When a mutation modifies thetodostable, Forge knows to re-run this query and push updates to all subscribers.QueryContext-- provides access to the database pool viactx.db().
Step 5: Create Mutations
Add input types and three mutations in the same file:
#[derive(Debug, Serialize, Deserialize)]
pub struct CreateTodoInput {
pub title: String,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct UpdateTodoInput {
pub id: Uuid,
pub title: Option<String>,
pub completed: Option<bool>,
}
#[forge::mutation(public)]
pub async fn create_todo(ctx: &MutationContext, input: CreateTodoInput) -> Result<Todo> {
if input.title.trim().is_empty() {
return Err(ForgeError::Validation("Title cannot be empty".into()));
}
let title = input.title.trim().to_string();
let mut conn = ctx.conn().await?;
sqlx::query_as!(
Todo,
"INSERT INTO todos (title) VALUES ($1) RETURNING *",
title
)
.fetch_one(&mut conn)
.await
.map_err(Into::into)
}
#[forge::mutation(public)]
pub async fn update_todo(ctx: &MutationContext, input: UpdateTodoInput) -> Result<Todo> {
let title = input.title.as_deref();
let mut conn = ctx.conn().await?;
sqlx::query_as!(
Todo,
"UPDATE todos
SET title = COALESCE($1, title),
completed = COALESCE($2, completed)
WHERE id = $3
RETURNING *",
title,
input.completed,
input.id
)
.fetch_optional(&mut conn)
.await?
.ok_or_else(|| ForgeError::NotFound("Todo not found".into()))
}
#[forge::mutation(public)]
pub async fn delete_todo(ctx: &MutationContext, id: Uuid) -> Result<bool> {
let mut conn = ctx.conn().await?;
let result = sqlx::query!("DELETE FROM todos WHERE id = $1", id)
.execute(&mut conn)
.await?;
Ok(result.rows_affected() > 0)
}
Mutations use MutationContext, which provides ctx.conn() for acquiring a database connection. When a mutation writes to a reactive table, the PostgreSQL trigger fires automatically -- you do not need to manually notify subscribers.
Use ForgeError::Validation for input errors and ForgeError::NotFound for missing records. Both return appropriate HTTP status codes.
Step 6: Generate Frontend Bindings
Run the code generator:
forge generate
This produces type-safe bindings in the frontend/ directory:
- SvelteKit
- Dioxus
Forge generates TypeScript in frontend/src/lib/forge/:
-
types.ts-- TypeScript interfaces matching your Rust structs:export interface Todo {
id: string;
title: string;
completed: boolean;
created_at: string;
}
export interface CreateTodoInput {
title: string;
}
export interface UpdateTodoInput {
id: string;
title?: string;
completed?: boolean;
} -
api.ts-- Typed functions for every query and mutation, plus subscription stores:export const listTodos = (): Promise<Todo[]> =>
getForgeClient().call("list_todos", null);
export const listTodosStore$ = () =>
createSubscriptionStore<null, Todo[]>("list_todos", null);
export const createTodo = (args: CreateTodoInput): Promise<Todo> =>
getForgeClient().call("create_todo", args);
export const deleteTodo = (args: { id: string }): Promise<boolean> =>
getForgeClient().call("delete_todo", args);
export const updateTodo = (args: UpdateTodoInput): Promise<Todo> =>
getForgeClient().call("update_todo", args);
Forge generates Rust in frontend/src/forge/:
-
types.rs-- Rust structs mirroring your backend models:pub struct CreateTodoInput {
pub title: String,
}
pub struct Todo {
pub id: String,
pub title: String,
pub completed: bool,
pub created_at: String,
}
pub struct UpdateTodoInput {
pub id: String,
pub title: Option<String>,
pub completed: Option<bool>,
} -
api.rs-- Hooks and async functions for every query and mutation:pub fn use_list_todos_live() -> SubscriptionState<Vec<Todo>> {
use_forge_subscription("list_todos", ())
}
pub fn use_create_todo() -> Mutation<CreateTodoInput, Todo> {
use_forge_mutation("create_todo")
}
pub fn use_delete_todo() -> Mutation<DeleteTodoParams, bool> {
use_forge_mutation("delete_todo")
}
pub fn use_update_todo() -> Mutation<UpdateTodoInput, Todo> {
use_forge_mutation("update_todo")
}
Step 7: Wire Up the Frontend
- SvelteKit
- Dioxus
In frontend/src/routes/+page.svelte:
<script lang="ts">
import { listTodos$, createTodo, updateTodo, deleteTodo } from "$lib/forge";
import type { ForgeError } from "$lib/forge";
const todos = listTodos$();
let newTitle: string = $state("");
let error: ForgeError | null = $state(null);
let adding: boolean = $state(false);
let remainingCount = $derived(
todos.data?.filter((t) => !t.completed).length ?? 0,
);
async function handleAdd() {
if (!newTitle.trim()) return;
adding = true;
error = null;
try {
await createTodo({ title: newTitle.trim() });
newTitle = "";
} catch (e) {
error = e as ForgeError;
} finally {
adding = false;
}
}
async function handleToggle(id: string, completed: boolean) {
try {
await updateTodo({ id, completed: !completed });
} catch (e) {
error = e as ForgeError;
}
}
async function handleDelete(id: string) {
try {
await deleteTodo({ id });
} catch (e) {
error = e as ForgeError;
}
}
</script>
{#if todos.loading}
<p>Loading...</p>
{:else if todos.data}
<ul>
{#each todos.data as todo (todo.id)}
<li>
<input
type="checkbox"
checked={todo.completed}
onchange={() => handleToggle(todo.id, todo.completed)}
/>
<span>{todo.title}</span>
<button onclick={() => handleDelete(todo.id)}>Delete</button>
</li>
{/each}
</ul>
<p>{remainingCount} remaining</p>
{/if}
The key pattern: listTodos$() returns a Svelte 5 reactive object with data, loading, and error fields. It opens an SSE connection under the hood and updates automatically when any client mutates the todos table. Mutations like createTodo() are plain async calls -- no manual cache invalidation needed.
In frontend/src/todo_app.rs:
use dioxus::prelude::*;
use crate::forge::{CreateTodoInput, use_create_todo, use_list_todos_live};
#[component]
pub fn TodoApp() -> Element {
let create_todo = use_create_todo();
let todo_state = use_list_todos_live();
let mut new_title = use_signal(String::new);
let mut error = use_signal(|| None::<String>);
let mut adding = use_signal(|| false);
let todo_items = todo_state.data.clone().unwrap_or_default();
let remaining_count = todo_items.iter().filter(|t| !t.completed).count();
rsx! {
if todo_state.loading {
p { "Loading..." }
} else {
ul {
for todo in todo_items {
li {
key: "{todo.id}",
span { "{todo.title}" }
}
}
}
p { "{remaining_count} remaining" }
}
input {
value: new_title(),
oninput: move |e| new_title.set(e.value()),
onkeydown: {
let create_todo = create_todo.clone();
move |event: KeyboardEvent| {
if event.key().to_string() == "Enter" {
let title = new_title().trim().to_string();
if title.is_empty() || adding() { return; }
adding.set(true);
let create_todo = create_todo.clone();
spawn(async move {
match create_todo.call(CreateTodoInput::new(title)).await {
Ok(_) => new_title.set(String::new()),
Err(err) => error.set(Some(err.message)),
}
adding.set(false);
});
}
}
},
}
}
}
The key pattern: use_list_todos_live() returns a SubscriptionState<Vec<Todo>> that automatically updates when any client mutates the todos table. use_create_todo() returns a Mutation handle whose .call() method sends the request. No manual refetching required.
Step 8: Run It
docker compose up --build
This starts three services:
- PostgreSQL on port 5432
- Forge backend on port 9081
- Frontend dev server on port 9080
Open http://localhost:9080 in two browser tabs. Add a todo in one tab and watch it appear instantly in the other.
How It Works
The real-time pipeline:
- A client calls
createTodo(), which sends a request to the Forge backend. - The mutation inserts a row into the
todostable. - The PostgreSQL trigger (installed by
forge_enable_reactivity) fires aNOTIFYevent. - Forge receives the notification via
LISTENand re-executes all queries that declaredtables = ["todos"]. - Forge hashes the new query result and compares it to the previous hash. If different, it pushes the updated data to all subscribed clients over SSE.
- The frontend binding (
listTodos$()oruse_list_todos_live()) receives the update and re-renders.
This means you get real-time sync with zero client-side cache management. Mutations write to the database, and the subscription pipeline handles the rest.
What's Next
- Add Authentication -- protect mutations with JWT-based auth
- Background Processing -- offload work with jobs, crons, and workflows
- Ship to Production -- deploy your app