Skip to main content

Your First App

Create a real-time todo list using Forge queries, mutations, and PostgreSQL-backed subscriptions.

Install Forge

curl -fsSL https://tryforge.dev/install.sh | sh

Requires Docker (with Compose v2).

Create and Run

forge new my-app --minimal
cd my-app
forge dev

This runs Docker Compose with three services:

ServiceURL
Frontendhttp://localhost:5173
Backendhttp://localhost:8080
PostgreSQLlocalhost:5432

Backend restarts are scoped to backend files (src/, migrations/, Cargo.toml, Cargo.lock, build.rs, .env, forge.toml) via an .ignore allowlist so unrelated files do not trigger rebuilds.

Define the Model

Create src/schema/todo.rs:

use forge::prelude::*;

#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
#[forge::model]
pub struct Todo {
pub id: Uuid,
pub title: String,
pub completed: bool,
pub created_at: DateTime<Utc>,
}

Export it in src/schema/mod.rs:

pub mod todo;
pub use todo::*;

Add a Query

Create src/functions/todos.rs:

use forge::prelude::*;
use crate::schema::Todo;

#[forge::query(public)]
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)
}

Export it in src/functions/mod.rs:

pub mod todos;
pub use todos::*;

Register it in src/main.rs:

Forge::builder()
.register_query::<functions::ListTodosQuery>()
// ... other registrations

Add the Table

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

-- @down
DROP TABLE IF EXISTS todos;

Test It

curl http://localhost:8080/_api/rpc/list_todos

Returns []. The query works.

Add a Mutation

Add to src/functions/todos.rs:

#[derive(Debug, Serialize, Deserialize)]
pub struct CreateTodoInput {
pub title: String,
}

#[forge::mutation(public)]
pub async fn create_todo(ctx: &MutationContext, input: CreateTodoInput) -> Result<Todo> {
let mut conn = ctx.conn().await?;
sqlx::query_as!(Todo, "INSERT INTO todos (title) VALUES ($1) RETURNING *", input.title)
.fetch_one(&mut *conn)
.await
.map_err(Into::into)
}

Register it in src/main.rs:

Add to the builder chain in src/main.rs:

Forge::builder()
.register_query::<functions::ListTodosQuery>()
.register_mutation::<functions::CreateTodoMutation>()
// ...

Test it:

curl -X POST http://localhost:8080/_api/rpc/create_todo \
-H "Content-Type: application/json" \
-d '{"title": "Learn Forge"}'

Make It Real-Time

Two changes needed:

1. Enable table reactivity:

Create migrations/0002_todos_reactivity.sql:

-- @up
SELECT forge_enable_reactivity('todos');

-- @down
SELECT forge_disable_reactivity('todos');

This sets up PostgreSQL triggers that emit NOTIFY on changes.

2. Use a subscription on the frontend.

The query itself doesn't need to change. Forge extracts table dependencies from the SQL at compile time.

Use from Frontend

Generate the TypeScript client:

forge generate

This creates type-safe bindings in frontend/src/lib/forge/.

Now use them in your Svelte component:

<script lang="ts">
import { listTodos$, createTodo } from '$lib/forge';

const todos = listTodos$();
let title = '';

async function add() {
await createTodo({ title });
title = '';
}
</script>

{#if todos.loading}
<p>Loading...</p>
{:else if todos.error}
<p>Error: {todos.error.message}</p>
{:else}
<ul>
{#each todos.data ?? [] as todo}
<li>{todo.title}</li>
{/each}
</ul>
{/if}

<input bind:value={title} />
<button onclick={add}>Add</button>

The $ suffix subscribes to real-time updates through the Forge SSE client, which relays PostgreSQL LISTEN/NOTIFY changes. Create a todo in one browser tab, it appears in all others.

What You Built

  • Query with compile-time SQL validation
  • Mutation with automatic type generation
  • Real-time subscriptions via PostgreSQL LISTEN/NOTIFY
  • Type-safe frontend client

Next: Project Anatomy to understand the file structure.