noboil

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

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

Pass --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 dev

Authentication 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 bindings

Re-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:generate

5. Run the dev server

bun dev

Open 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 detection
  • list, read, search — paginated queries with where clause support
  • File upload, image compression, automatic cleanup on delete
  • Soft delete with restore endpoint (when softDelete: true)
  • Per-item ACL via editors[] (when acl: true)
  • Org membership checks (for orgScoped tables)

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 changes

Open 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 (pub option)
  • 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:generate

The new field appears in forms (AutoForm) and queries automatically.

Next steps

On this page