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 --template with-svelte/minimal
cd my-app
forge dev

This template starts with a SvelteKit frontend. To start with Dioxus instead, use forge new my-app --template with-dioxus/minimal.

This runs Docker Compose with the frontend and backend exposed on your host:

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

PostgreSQL runs inside the Docker network by default and is not published on localhost:5432. Use docker compose exec db psql ... if you want to inspect it directly.

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::*;

#[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>,
}

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 -X POST http://localhost:8080/_api/rpc/list_todos \
-H "Content-Type: application/json" \
-d '{}'

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 '{"args": {"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.

If you change SQL or migrations and want to refresh the offline cache used by sqlx macros and forge check, run:

forge migrate prepare

Use from Frontend

Generate frontend bindings:

forge generate

This creates type-safe bindings in the default output for your target:

  • frontend/src/lib/forge/ for SvelteKit
  • frontend/src/forge/ for Dioxus

SvelteKit example

The with-svelte/minimal template uses SvelteKit, so you can use the generated TypeScript bindings like this:

<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.

Dioxus example

With --target dioxus, Forge generates Rust bindings and hooks instead:

use dioxus::prelude::*;
use crate::forge::use_list_todos_live;

#[component]
fn TodoList() -> Element {
let todos = use_list_todos_live();

rsx! {
ul {
for todo in todos.data.clone().unwrap_or_default() {
li { "{todo.title}" }
}
}
}
}

use_list_todos() fetches once. use_list_todos_live() subscribes to real-time changes.

What You Built

  • Query with compile-time SQL validation
  • Mutation with automatic type generation
  • Real-time subscriptions via PostgreSQL LISTEN/NOTIFY
  • Type-safe frontend bindings for SvelteKit or Dioxus

Next: Project Anatomy to understand the file structure.