Skip to main content

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 the todos table, Forge knows to re-run this query and push updates to all subscribers.
  • QueryContext -- provides access to the database pool via ctx.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:

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);

Step 7: Wire Up the Frontend

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.

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:

  1. A client calls createTodo(), which sends a request to the Forge backend.
  2. The mutation inserts a row into the todos table.
  3. The PostgreSQL trigger (installed by forge_enable_reactivity) fires a NOTIFY event.
  4. Forge receives the notification via LISTEN and re-executes all queries that declared tables = ["todos"].
  5. 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.
  6. The frontend binding (listTodos$() or use_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