Guide
Build a live task list
Save tasks to PostgreSQL and see changes in two browser tabs.
On this page
Before you start
- A React TypeScript Vite app.
- Database packages, a supported native platform, and PostgreSQL.
Build a React task list backed by PostgreSQL. Clicking Add task saves a row; every open copy of the page receives the updated list without reloading.
Start in a React TypeScript Vite app with the database packages, Zod and
TypeScript installed as described in installation.
Keep the Vite entrypoints src/main.tsx and src/App.tsx. Put your PostgreSQL
connection URL in the app’s .env file:
DATABASE_URL=postgresql://user:password@localhost:5432/my_app- 1Define the table and connection
Describe the rows, then tell the server where PostgreSQL lives. Create
db/schema.tsat the project root. The table gets a generatedid; new tasks default tocompleted: false:db/schema.tsimport { defineSchema, defineTable, v } from '@hulla/db/schema' export default defineSchema({ tasks: defineTable({ title: v.string(), completed: v.boolean().default(false), }), })Keep connection settings in
db.config.ts, outside browser code. Allow the Vite app to connect to the development service:db.config.tsimport { defineConfig } from '@hulla/db/config' export default defineConfig({ database: { server: { url: process.env.DATABASE_URL! }, client: { url: 'http://localhost:3001', credentials: 'include' }, }, service: { origins: ['http://localhost:5173'] }, })Generate the schema-bound constructors before declaring your functions:
Terminalbun --bun hulla-db generateThe command prints
Generated database entrypoints.and createsdb/_generated/. It does not create database tables or start a server. - 2Declare a query and a mutation
Read saved tasks and validate each new title before writing. Create
db/tasks.ts. The query returns rows; the mutation inserts one row in a transaction and returns it:db/tasks.tsimport { query, mutation } from './_generated/functions' import { z } from 'zod' export const list = query((ctx) => ctx.db.select('tasks').orderBy('title').limit(100) ) export const create = mutation({ input: z.object({ title: z.string().min(1) }), handler: (ctx, input) => ctx.db.insert('tasks', input), })Exporting these declarations makes them browser-callable. Generation discovers them beside the schema; you do not register routes or maintain another export file. The handler always receives
ctxfirst and validated input second. - 3Initialize storage and start development
Provision the table explicitly, then leave the query service running. From the app directory, initialize the schema and start the watcher:
Terminalbun --bun hulla-db init bun --bun hulla-db devinitprintsDatabase initialized and schema verified.The watcher generates the new function bindings and starts the service athttp://127.0.0.1:3001. Leave this terminal running.devreloads declaration changes; it never resets database data or silently applies an incompatible schema change. - 4Mount the browser app
Generated callables use the public connection settings automatically. Replace
src/main.tsxwith the browser entry below. The generated runtime knows your function contracts and public service settings:src/main.tsximport { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import App from './App' createRoot(document.getElementById('root')!).render( <StrictMode> <App /> </StrictMode> )This file belongs in the browser. The URL points to the query service, not PostgreSQL; the private connection stays in
db.config.ts. Importing the generated callables uses the public settings without opening a connection. For application teardown, see connection ownership. - 5Render the live list and add a task
Pass generated functions to hooks; call the mutation from an event. Replace
src/App.tsx:src/App.tsximport { useMutation, useQuery } from '@hulla/db-react' import { create, list } from '../db/_generated/client/tasks' export default function App() { const tasks = useQuery(list) const add = useMutation(create) return ( <main> <h1>Tasks</h1> {tasks.status === 'pending' && !tasks.data && <p>Loading…</p>} {tasks.status === 'error' && <p>Could not load tasks.</p>} <ul> {tasks.data?.map((task) => ( <li key={task.id}>{task.title}</li> ))} </ul> <button onClick={() => { void add({ title: 'My first task' }) }}> Add task </button> {add.state.status === 'error' && <p>Could not save the task.</p>} </main> ) }In a second terminal, start Vite on the origin allowed by your config:
Terminalbun run dev --port 5173 --strictPortOpen
http://localhost:5173in two tabs. Click Add task in one. Both lists update after the commit, and the task remains after a reload. An empty database starts with an empty list; there is no seed or local replica to initialize.
Use schemas to add fields and constraints,
queries and mutations to add input and shared guards, and
authentication before handling private data.
For SSR and framework-specific entrypoints, choose a
full-stack recipe. If your app only needs server-side
database access, start with direct db operations instead.