Quickstart
From zero to a running app with authenticated CRUD in minutes.
This guide takes you from zero to a working app with real-time CRUD and authentication. Snippets are written in single voice — pick the import path that matches your backend. The handful of places where the two backends genuinely differ live in Convex vs SpacetimeDB.
Prerequisites
- Bun installed
- For Convex: a Convex account (free tier works) — or use the bundled self-hosted Docker Compose
- For SpacetimeDB: Docker and the SpacetimeDB CLI v2.0+
1. Scaffold the project
noboil init clones a fully-wired template, removes the parts you don't need, patches package.json, and runs bun install. It must target an empty directory.
bunx noboil@latest init my-app --db=convex # or --db=spacetimedb
cd my-appPass --no-demos to skip the demo apps, or --skip-install to skip bun install. With both flags plus a target directory, init runs non-interactively.
2. Start the backend
The template includes a backend/convex workspace with s.ts (Zod schemas), lazy.ts (your noboil() call), and one example module per table. Boot Convex:
bunx convex devAuthentication is wired through @convex-dev/auth. See the Convex Auth guide to configure providers — every CRUD endpoint enforces auth by default.
The template ships spacetimedb.yml (Docker Compose) and a backend/spacetimedb workspace with s.ts (Zod schemas) and src/index.ts (the noboil() module). Boot SpacetimeDB and publish:
bun spacetime:up # Docker Compose: SpacetimeDB on :4200
bun spacetime:publish # publish module
bun spacetime:generate # generate TypeScript bindingsRe-run spacetime:generate after any schema change.
3. Define your schema
Edit s.ts to define your tables. The schema() wrapper accepts the slot names owned, orgScoped, base, singleton, org, children, log, kv, quota.
import { schema } from 'noboil/convex/schema'
import { boolean, object, string } from 'zod/v4'
export const s = schema({
owned: {
blog: object({
title: string().min(1),
content: string().min(3),
published: boolean()
})
}
})4. Wire it up with noboil()
noboil() registers every table in one call.
// backend/convex/lazy.ts
import { noboil } from 'noboil/convex/server'
import { action, internalMutation, internalQuery, mutation, query } from './convex/_generated/server'
import { getAuthUserId } from '@convex-dev/auth/server'
import { s } from './s'
export const api = noboil({
query, mutation, action, internalQuery, internalMutation, getAuthUserId,
tables: ({ table }) => ({
blog: table(s.blog, { rateLimit: 10, search: 'content' })
})
})Then in convex/blog.ts, re-export the generated endpoints so Convex picks them up:
import { api } from '../lazy'
export const { create, update, rm, pub: { list, read, search } } = api.blog// backend/spacetimedb/src/index.ts
import { noboil } from 'noboil/spacetimedb/server'
import { s } from '../s'
export default noboil({ tables: ({ table }) => ({
blog: table(s.blog, { pub: 'published', rateLimit: 10 })
}) })noboil() builds the schema, registers reducers (create_blog, update_blog, rm_blog), and adds row-level subscription filters from the pub option. Republish + regenerate after any change:
bun spacetime:publish && bun spacetime:generate5. Run the dev server
bun devOpen the demo app on the port printed in the terminal.
6. Connect from React
The Providers component is generated by noboil init — you usually don't need to touch it. See the demo app/providers.tsx if you need to customize.
7. Query and write data
useList, useFormMutation, and Form from noboil/convex/components and /react:
'use client'
import { useList } from 'noboil/convex/react'
import { Form, useFormMutation } from 'noboil/convex/components'
import { api } from '@/backend/lazy'
import { s } from '@/backend/s'
const BlogPage = () => {
const { items, loadMore, hasMore } = useList(api.blog.list, { where: { published: true } })
const form = useFormMutation({ mutation: api.blog.create, schema: s.blog })
return (
<>
<ul>{items.map(b => <li key={b._id}>{b.title}</li>)}</ul>
{hasMore && <button onClick={loadMore}>Load more</button>}
<Form form={form} render={({ Text, Toggle, Submit }) => (
<>
<Text name="title" />
<Text name="content" multiline />
<Toggle name="published" />
<Submit>Publish</Submit>
</>
)} />
</>
)
}name='title' is checked at compile time. <Toggle name='title' /> is a type error because title is a string, not a boolean.
For the SpacetimeDB equivalents and the small set of API differences, see Convex vs SpacetimeDB.
What you get for free
From a single table(s.blog, opts) call:
create,update,rm— validated mutations with auth + rate limiting + conflict detectionlist,read,search— paginated queries withwhereclause support- File upload, image compression, automatic cleanup on delete
- Soft delete with
restoreendpoint (whensoftDelete: true) - Per-item ACL via
editors[](whenacl: true) - Org membership checks (for
orgScopedtables)
Updates from any client appear in all connected clients in real time.
TypeScript type inference
Field names are checked at compile time. Pass the wrong type to a field and TypeScript catches it. The s namespace returned by schema() carries the brand, so api.blog.create({ title: 1 }) is a compile error.
Your first 10 minutes
After noboil init my-app completes, here's what you have:
my-app/
backend/convex/ or backend/spacetimedb/
lib/convex/ or lib/spacetimedb/
web/cvx/blog/ or web/stdb/blog/
doc/
readonly/ui/
package.json
.noboilrc.json ← project manifest (don't edit)Start the backend:
# Convex
bun setup:convex # generates keys, deploys backend
bun dev # starts Next.js
# SpacetimeDB
bun setup:spacetimedb # starts Docker, publishes module
bun spacetime:generate # generates TypeScript bindings
noboil stdb dev # starts Next.js + watches for schema changesOpen the app at http://localhost:4110. You'll see the blog demo with:
- Login (email or Google)
- Create / edit / delete posts (owned CRUD)
- Public vs private toggle (
puboption) - File upload (cover image)
- Pagination and search
- Real-time updates (try two browser tabs)
Make a change: edit s.ts to add a field, then:
# Convex: deploy + restart
cd backend/convex && bun with-env npx convex dev --once
# SpacetimeDB: publish + generate + restart
bun spacetime:publish && bun spacetime:generateThe new field appears in forms (AutoForm) and queries automatically.
Next steps
- Data fetching — filtering, pagination, SSR
- Forms — typesafe form components and validation
- Organizations — multi-tenant apps
- Security — auth enforcement, RLS, rate limiting
- Schema evolution — safe field changes in production
- Testing — test helpers and integration tests
- Convex vs SpacetimeDB — every place the libs diverge