noboil

Recipes

Real-world composition patterns — schema, backend, and frontend for each recipe.

Recipes index

30 recipes (auto-generated TOC):

Features: owned CRUD · file upload · rate limiting · search · where clauses

Schema

import { file, makeOwned } from 'noboil/convex/schema'
import { boolean, object, string, enum as zenum } from 'zod/v4'

const owned = makeOwned({
  blog: object({
    title: string().min(1),
    content: string().min(3),
    category: zenum(['tech', 'life', 'tutorial']),
    published: boolean(),
    coverImage: file().nullable().optional()
  })
})

Backend

export const {
  create,
  pub: { list, read, search },
  rm,
  update
} = crud('blog', owned.blog, {
  rateLimit: { max: 10, window: 60_000 },
  search: 'content'
})

Frontend

import { useList } from 'noboil/convex/react'
import { Form, useForm } from 'noboil/convex/components'

const BlogPage = () => {
  const { items, loadMore, status } = useList(api.blog.list, {
    where: { published: true }
  })
  const searched = useList(api.blog.search, { query: 'react hooks' })

  return (
    <ul>
      {items.map(b => (
        <li key={b._id}>
          {b.coverImageUrl && <img src={b.coverImageUrl} alt="" />}
          <h2>{b.title}</h2>
        </li>
      ))}
      {status === 'CanLoadMore' && (
        <button onClick={loadMore}>Load more</button>
      )}
    </ul>
  )
}

const CreateBlog = () => {
  const form = useForm({
    schema: owned.blog,
    onSubmit: async d => {
      await create(d)
      return d
    }
  })

  return (
    <Form
      form={form}
      render={({ Text, Choose, Toggle, File, Submit }) => (
        <>
          <Text name="title" />
          <Text name="content" multiline />
          <Choose name="category" />
          <Toggle name="published" />
          <File name="coverImage" accept="image/*" />
          <Submit>Publish</Submit>
        </>
      )}
    />
  )
}

Schema

import { file, schema } from 'noboil/spacetimedb/schema'
import { boolean, object, string, enum as zenum } from 'zod/v4'

const s = schema({
  owned: {
    blog: object({
      title: string().min(1),
      content: string().min(3),
      category: zenum(['tech', 'life', 'tutorial']),
      published: boolean(),
      coverImage: file().nullable().optional()
    })
  }
})

export { s }

Backend module

import { noboil } from 'noboil/spacetimedb/server'
import { s } from '../s'

export default noboil({ tables: ({ table }) => ({
  blog: table(s.blog, { pub: 'published', rateLimit: 10 })
}) })

This generates create_blog, update_blog, and rm_blog reducers with auth, ownership, conflict detection, and rate limiting. pub: 'published' lets every client see published posts plus their own drafts.

Frontend

import { Form, useForm, useFormMutation } from 'noboil/spacetimedb/components'
import { useList } from 'noboil/spacetimedb/react'
import { useTable, useReducer } from 'spacetimedb/react'
import { reducers, tables } from '@/generated/module_bindings'
import { s } from '../../s'

const BlogPage = () => {
  const [allBlogs, isReady] = useTable(tables.blog)
  const { data, hasMore, loadMore } = useList(allBlogs, isReady, {
    where: { published: true },
    sort: { field: 'updatedAt', direction: 'desc' },
    pageSize: 20
  })

  return (
    <ul>
      {data.map(b => <li key={b.id}>{b.title}</li>)}
      {hasMore && <button onClick={loadMore}>Load more</button>}
    </ul>
  )
}

const CreateBlog = () => {
  const createBlog = useReducer(reducers.create_blog)
  const form = useFormMutation({
    schema: s.blog,
    mutate: d => createBlog(d),
    toast: 'Created'
  })

  return (
    <Form
      form={form}
      render={({ Text, Choose, Toggle, File, Submit }) => (
        <>
          <Text name="title" />
          <Text name="content" multiline />
          <Choose name="category" />
          <Toggle name="published" />
          <File name="coverImage" accept="image/*" />
          <Submit>Publish</Submit>
        </>
      )}
    />
  )
}

Org CRUD with ACL and Cascade Delete

Features: org multi-tenancy · per-item ACL · soft delete · cascade · permission guard

Schema

import { makeOrgScoped } from 'noboil/convex/schema'
import { array, boolean, number, object, string, enum as zenum } from 'zod/v4'

const orgScoped = makeOrgScoped({
  project: object({
    name: string().min(1),
    description: string(),
    status: zenum(['active', 'archived']),
    editors: array(zid('users')).optional()
  }),
  task: object({
    projectId: zid('project'),
    title: string().min(1),
    priority: number(),
    done: boolean(),
    deletedAt: number().optional()
  })
})

Backend

import { orgCascade } from 'noboil/convex/server'

export const {
  addEditor,
  create,
  editors,
  list,
  read,
  removeEditor,
  rm,
  setEditors,
  update
} = orgCrud('project', orgScoped.project, {
  acl: true,
  cascade: orgCascade(orgScoped.task, {
    foreignKey: 'projectId',
    table: 'task'
  })
})

export const {
  create: createTask,
  list: listTasks,
  rm: rmTask,
  update: updateTask,
  restore
} = orgCrud('task', orgScoped.task, {
  aclFrom: { field: 'projectId', table: 'project' },
  softDelete: true
})

Frontend

import { useOrgQuery, useOrgMutation } from 'noboil/convex/react'
import { EditorsSection, PermissionGuard } from 'noboil/convex/components'

const ProjectPage = ({ projectId }: { projectId: Id<'project'> }) => {
  const project = useOrgQuery(api.project.read, { id: projectId })
  const tasks = useOrgQuery(api.task.list, {
    paginationOpts: { cursor: null, numItems: 50 }
  })
  const remove = useOrgMutation(api.project.rm)

  return (
    <>
      <PermissionGuard doc={project} fallback={<p>View only</p>}>
        <button onClick={() => remove({ id: projectId })}>Delete</button>
      </PermissionGuard>
      <EditorsSection docId={projectId} api={api.project} />
      <ul>
        {tasks?.page.map(t => (
          <li key={t._id}>{t.title}</li>
        ))}
      </ul>
    </>
  )
}

Schema

s.ts:

import { schema } from 'noboil/spacetimedb/schema'
import { object, string } from 'zod/v4'

const s = schema({
  orgScoped: {
    project: object({ description: string().optional(), name: string().min(1) })
  }
})

export { s }

index.ts:

import { noboil } from 'noboil/spacetimedb/server'
import { s } from '../s'

export default noboil({ tables: ({ table }) => ({
  project: table(s.project)
}) })

Client

'use client'

import { useTable, useReducer } from 'spacetimedb/react'
import { useList, useOrg, useOrgMutation } from 'noboil/spacetimedb/react'
import { tables, reducers } from '@/generated/module_bindings'

const ProjectList = () => {
  const { org } = useOrg()
  const [allProjects, isReady] = useTable(tables.project)

  const { data: projects } = useList(allProjects, isReady, {
    where: { orgId: org.id },
    sort: { field: 'updatedAt', direction: 'desc' },
  })

  const createProject = useOrgMutation(useReducer(reducers.create_project))

  return (
    <div>
      <button onClick={() => createProject({ name: 'New project' })}>
        New project
      </button>
      <ul>
        {projects.map(p => (
          <li key={p.id}>{p.name}</li>
        ))}
      </ul>
    </div>
  )
}

useOrgMutation injects orgId automatically on every call.


Cache with External API

Features: TTL · external API · load/refresh · rate limiting

Schema

import { makeBase } from 'noboil/convex/schema'
import { number, object, string } from 'zod/v4'

const base = makeBase({
  movie: object({
    tmdb_id: number(),
    title: string(),
    overview: string(),
    poster_path: string().nullable(),
    vote_average: number()
  })
})

Backend

export const { all, get, load, refresh, invalidate, purge } = cacheCrud({
  table: 'movie',
  schema: base.movie,
  key: 'tmdb_id',
  ttl: 86400,
  fetcher: async (_, tmdbId) => {
    const res = await fetch(
      `https://api.themoviedb.org/3/movie/${tmdbId}?api_key=${process.env.TMDB_KEY}`
    )
    const { id, title, overview, poster_path, vote_average } = await res.json()
    return { tmdb_id: id, title, overview, poster_path, vote_average }
  },
  rateLimit: { max: 30, window: 60_000 }
})

Frontend

import { useQuery, useMutation } from 'convex/react'

const MoviePage = ({ tmdbId }: { tmdbId: number }) => {
  const movie = useQuery(api.movie.get, { key: tmdbId })
  const loadMovie = useMutation(api.movie.load)
  const refreshMovie = useMutation(api.movie.refresh)

  if (movie === undefined) {
    loadMovie({ key: tmdbId })
    return <p>Loading...</p>
  }

  return (
    <>
      <h1>{movie.title}</h1>
      <p>{movie.overview}</p>
      <p>Rating: {movie.vote_average}/10</p>
      <button onClick={() => refreshMovie({ key: tmdbId })}>Refresh</button>
    </>
  )
}

Schema

s.ts:

import { schema } from 'noboil/spacetimedb/schema'
import { number, object, string } from 'zod/v4'

const s = schema({
  base: {
    movie: object({
      overview: string(),
      title: string(),
      tmdbId: number(),
      voteAverage: number()
    })
  }
})

export { s }

index.ts:

import { noboil } from 'noboil/spacetimedb/server'
import { s } from '../s'

export default noboil({ tables: ({ table }) => ({
  movie: table(s.movie, { key: 'tmdbId' })
}) })

Next.js API route for cache population

Since ctx.http.fetch() panics in local Docker, use a Next.js API route to fetch from the external API and populate the cache. SpacetimeDB's SQL API does not support parameterized queries, but since tmdbId is validated as a number, injection is prevented at the validation layer.

import { NextResponse } from 'next/server'

const STDB_URI = process.env.SPACETIMEDB_URI ?? 'http://localhost:4200'
const MODULE = process.env.MODULE_NAME ?? 'my-app'
const TMDB_API_KEY = process.env.TMDB_API_KEY

export const GET = async (
  _req: Request,
  { params }: { params: { tmdbId: string } }
) => {
  const tmdbId = Number(params.tmdbId)

  const cacheRes = await fetch(`${STDB_URI}/v1/database/${MODULE}/sql`, {
    method: 'POST',
    headers: { 'Content-Type': 'text/plain' },
    body: `SELECT * FROM movie WHERE tmdb_id = ${tmdbId} AND invalidated_at IS NULL LIMIT 1`
  })
  const [cacheResult] = (await cacheRes.json()) as [{ rows: unknown[][] }]

  if (cacheResult.rows.length > 0) {
    return NextResponse.json({ source: 'cache', data: cacheResult.rows[0] })
  }

  const tmdbRes = await fetch(
    `https://api.themoviedb.org/3/movie/${tmdbId}?api_key=${TMDB_API_KEY}`
  )
  const movie = (await tmdbRes.json()) as {
    id: number
    title: string
    overview: string
    vote_average: number
  }

  return NextResponse.json({ source: 'api', data: movie })
}

Client

'use client'

import { useTable } from 'spacetimedb/react'
import { useList } from 'noboil/spacetimedb/react'
import { tables } from '@/generated/module_bindings'

const MovieList = () => {
  const [movies, isReady] = useTable(tables.movie)
  const { data } = useList(movies, isReady, {
    where: { invalidatedAt: undefined },
    sort: { field: 'voteAverage', direction: 'desc' },
  })

  return (
    <ul>
      {data.map(movie => (
        <li key={movie.id}>
          {movie.title} ({movie.voteAverage.toFixed(1)})
        </li>
      ))}
    </ul>
  )
}

Soft Delete with Restore

Soft delete is available on any orgCrud table via softDelete: true. The restore mutation is generated automatically.

export const {
  create: createTask,
  list: listTasks,
  rm: rmTask,
  update: updateTask,
  restore
} = orgCrud('task', orgScoped.task, {
  softDelete: true
})

Filter active vs deleted rows using where:

import { useList } from 'noboil/convex/react'

const { items: activeTasks } = useList(api.task.listTasks, {
  where: { deletedAt: undefined }
})

Schema

s.ts:

import { schema } from 'noboil/spacetimedb/schema'
import { object, string } from 'zod/v4'

const s = schema({
  orgScoped: {
    wiki: object({ content: string().optional(), title: string().min(1) })
  }
})

export { s }

index.ts:

import { noboil } from 'noboil/spacetimedb/server'
import { s } from '../s'

export default noboil({ tables: ({ table }) => ({
  wiki: table(s.wiki, { softDelete: true })
}) })

Client

Filter out deleted rows:

const { data: activeWikis } = useList(wikis, isReady, {
  where: { deletedAt: undefined }
})

const { data: deletedWikis } = useList(wikis, isReady, {
  where: { deletedAt: { $gt: 0 } }
})

Restore by setting deletedAt back to null:

const updateWiki = useReducer(reducers.update_wiki)

const restore = async (id: number) => {
  await updateWiki({ id, deletedAt: null })
}

Real-Time Presence Tracking

Features: presence · cursor tracking · online status · typing indicators

Schema

import { presenceTable } from 'noboil/convex/server'

export default defineSchema({
  ...presenceTable()
})

Backend

import { makePresence } from 'noboil/convex/server'

export const { heartbeat, list } = makePresence({
  mutation,
  query
})

Frontend

import { usePresence } from 'noboil/convex/react'

const CollaborativeEditor = ({ docId }: { docId: string }) => {
  const refs = {
    heartbeat: api.presence.heartbeat,
    leave: api.presence.leave,
    list: api.presence.list,
  }
  const { users, updatePresence } = usePresence(refs, docId, {
    data: { cursor: { x: 0, y: 0 }, status: 'viewing' as const }
  })

  const handleMouseMove = (e: React.MouseEvent) => {
    updatePresence({
      cursor: { x: e.clientX, y: e.clientY },
      status: 'editing'
    })
  }

  return (
    <div onMouseMove={handleMouseMove}>
      {users.map(p => (
        <div
          key={p.userId}
          className="absolute size-4 rounded-full bg-blue-500"
          style={{ left: p.data.cursor.x, top: p.data.cursor.y }}
        />
      ))}
    </div>
  )
}

Schema

index.ts:

import { noboil, makePresence } from 'noboil/spacetimedb/server'
import { s } from '../s'

export default noboil({ tables: ({ table, t }) => {
  const presence = makePresence({
    dataField: t.string(),
    roomIdField: t.string()
  })

  return {
    chat: table(s.chat, { index: ['isPublic'] }),
    message: table(s.message),
    ...presence.tables
  }
} })

Frontend

'use client'

import { useTable, useReducer } from 'spacetimedb/react'
import { usePresence } from 'noboil/spacetimedb/react'
import { tables, reducers } from '@/generated/module_bindings'

const CollaborativeRoom = ({ roomId }: { roomId: string }) => {
  const [presenceRows] = useTable(tables.presence)
  const heartbeat = useReducer(reducers.presence_heartbeat_presence)
  const { users, updatePresence } = usePresence(
    presenceRows,
    async ({ data } = {}) => heartbeat({ roomId, data: JSON.stringify(data ?? {}) }),
  )

  const handleMouseMove = (e: React.MouseEvent) => {
    updatePresence({ cursor: { x: e.clientX, y: e.clientY } })
  }

  return (
    <div onMouseMove={handleMouseMove}>
      <span>{users.length} online</span>
    </div>
  )
}

Real-Time Chat

Features: rooms · messages · live presence

Both demo apps ship a working chat implementation — see web/cvx/chat and web/stdb/chat. The SpacetimeDB version is shown below; the Convex version uses crud('chat', owned.chat) + childCrud('message', children.message) + usePresence.

Schema

s.ts:

import { child, schema } from 'noboil/spacetimedb/schema'
import { boolean, object, string } from 'zod/v4'

const s = schema({
  owned: {
    chat: object({ isPublic: boolean(), title: string().min(1) })
  },
  children: {
    message: child('chat', object({ content: string() }))
  }
})

export { s }

index.ts:

import { noboil, makePresence } from 'noboil/spacetimedb/server'
import { s } from '../s'

export default noboil({ tables: ({ table, t }) => {
  const presence = makePresence({
    dataField: t.string(),
    roomIdField: t.string()
  })

  return {
    chat: table(s.chat, { index: ['isPublic'] }),
    message: table(s.message),
    ...presence.tables
  }
} })

Chat component

'use client'

import { useTable, useReducer } from 'spacetimedb/react'
import { useList, usePresence } from 'noboil/spacetimedb/react'
import { tables, reducers } from '@/generated/module_bindings'

const ChatRoom = ({ chatId }: { chatId: number }) => {
  const [allMessages, messagesReady] = useTable(
    tables.message.where(r => r.chatId.eq(chatId))
  )
  const { data: messages } = useList(allMessages, messagesReady, {
    sort: { field: 'id', direction: 'asc' },
  })

  const [presenceRows] = useTable(tables.presence)
  const heartbeat = useReducer(reducers.presence_heartbeat_presence)
  const { users, updatePresence } = usePresence(
    presenceRows,
    async ({ data } = {}) => heartbeat({ roomId: String(chatId), data: JSON.stringify(data ?? {}) }),
  )

  const createMessage = useReducer(reducers.create_message)

  const handleSend = async (content: string) => {
    await createMessage({ chatId, content })
  }

  const handleMouseMove = (e: React.MouseEvent) => {
    updatePresence({ cursor: { x: e.clientX, y: e.clientY } })
  }

  return (
    <div onMouseMove={handleMouseMove}>
      <div>{users.length} online</div>
      <ul>
        {messages.map(msg => (
          <li key={msg.id}>{msg.content}</li>
        ))}
      </ul>
      <MessageInput onSend={handleSend} />
    </div>
  )
}

File Upload

Use file() in your schema — file storage works automatically on both backends. Convex uses built-in storage; SpacetimeDB stores files inline as byte arrays (up to ~100MB).

const owned = makeOwned({
  post: object({
    title: string().min(1),
    coverImage: file().nullable().optional()
  })
})
<Form form={form} render={({ Text, File, Submit }) => (
  <>
    <Text name="title" />
    <File name="coverImage" accept="image/*" />
    <Submit>Save</Submit>
  </>
)} />

The <File> component handles upload, preview, and storage registration — it just works.

Displaying uploaded files after reload

Blob URLs created during upload are session-scoped — they die on page reload. Use resolveFileUrl to resolve file references from the subscribed file table:

import { resolveFileUrl } from 'noboil/spacetimedb/react'
import { useTable } from 'spacetimedb/react'

const Avatar = ({ fileRef }: { fileRef: string | null }) => {
  const [files] = useTable(tables.file)
  const src = fileRef ? resolveFileUrl(files, fileRef) : null
  return src ? <img src={src} /> : null
}

resolveFileUrl matches the reference against file rows by filename or id, converts the stored bytes to a blob URL, and caches the result. Works with any value stored by useUpload.


Multi-Step Onboarding Form

Features: defineSteps · per-step validation · typed merged data · step navigation

defineSteps is exported from both noboil/convex/components and noboil/spacetimedb/components with an identical API — only the import path and the mutation wiring inside onSubmit differ. The Convex version is shown below.

Schema

import { file } from 'noboil/convex/schema'
import { object, string, enum as zenum } from 'zod/v4'

const profileStep = object({
  displayName: string().min(1),
  avatar: file().nullable().optional()
})

const orgStep = object({
  name: string().min(2),
  slug: string()
    .min(2)
    .regex(/^[a-z0-9-]+$/)
})

const preferencesStep = object({
  theme: zenum(['light', 'dark', 'system']),
  language: zenum(['en', 'es', 'fr', 'de'])
})

Backend

export const { upsert } = singletonCrud('profile', singleton.profile)

export const { create: createOrg } = orgFns({
  mutation,
  query,
  internalMutation,
  internalQuery
})

Frontend

import { defineSteps } from 'noboil/convex/components'
import { useMutation } from 'convex/react'

const { StepForm, useStepper } = defineSteps(
  { id: 'profile', label: 'Profile', schema: profileStep },
  { id: 'org', label: 'Organization', schema: orgStep },
  { id: 'preferences', label: 'Preferences', schema: preferencesStep }
)

const Onboarding = () => {
  const upsert = useMutation(api.profile.upsert)
  const createOrg = useMutation(api.org.create)

  const stepper = useStepper({
    onSubmit: async d => {
      await upsert({
        displayName: d.profile.displayName,
        avatar: d.profile.avatar
      })
      await createOrg({ name: d.org.name, slug: d.org.slug })
    },
    onSuccess: () => router.push('/dashboard')
  })

  return (
    <StepForm stepper={stepper} submitLabel="Complete">
      <StepForm.Step
        id="profile"
        render={({ Text, File }) => (
          <>
            <Text name="displayName" />
            <File name="avatar" accept="image/*" />
          </>
        )}
      />
      <StepForm.Step
        id="org"
        render={({ Text }) => (
          <>
            <Text name="name" />
            <Text name="slug" />
          </>
        )}
      />
      <StepForm.Step
        id="preferences"
        render={({ Choose }) => (
          <>
            <Choose name="theme" />
            <Choose name="language" />
          </>
        )}
      />
    </StepForm>
  )
}

Singleton Profile with File Upload

Features: singletonCrud · 1:1 per-user · file upload · upsert

singletonCrud from a singleton: { profile } schema slot. The Convex flow is shown below; the SpacetimeDB version uses the same schema with inline byte storage.

Schema

import { file, makeSingleton } from 'noboil/convex/schema'
import { object, string, enum as zenum } from 'zod/v4'

const singleton = makeSingleton({
  profile: object({
    displayName: string().min(1),
    bio: string().optional(),
    avatar: file().nullable().optional(),
    theme: zenum(['light', 'dark', 'system'])
  })
})

Backend

export const { get, upsert } = singletonCrud('profile', singleton.profile)

Frontend

import { useQuery } from 'convex/react'
import { Form, useForm } from 'noboil/convex/components'
import { pickValues } from 'noboil/convex/zod'

const ProfilePage = () => {
  const profile = useQuery(api.profile.get)
  const upsert = useMutation(api.profile.upsert)

  const form = useForm({
    schema: singleton.profile,
    values: profile ? pickValues(singleton.profile, profile) : undefined,
    onSubmit: async d => {
      await upsert(d)
      return d
    }
  })

  return (
    <Form
      form={form}
      render={({ Text, File, Choose, Submit }) => (
        <>
          <Text name="displayName" />
          <Text name="bio" multiline />
          <File name="avatar" accept="image/*" />
          <Choose name="theme" />
          <Submit>Save</Submit>
        </>
      )}
    />
  )
}

Custom Queries Alongside CRUD (Convex)

The pq / q / m builders are part of the Convex setup flow. SpacetimeDB users write custom reducers directly in their module file alongside the noboil() call — see Custom Queries for the SpacetimeDB equivalents.

Features: pq/q/m escape hatches · typed args · coexistence with CRUD

Backend

import { z } from 'zod/v4'

export const { create, list, read, rm, update } = crud('blog', owned.blog, {
    rateLimit: { max: 10, window: 60_000 }
  }),
  stats = pq({
    args: { category: z.string().optional() },
    handler: async (c, { category }) => {
      const docs = await c.db.query('blog').collect()
      let total = 0
      let published = 0
      for (const d of docs) {
        if (category && d.category !== category) continue
        total++
        if (d.published) published++
      }
      return { total, published, draft: total - published }
    }
  }),
  bySlug = pq({
    args: { slug: z.string() },
    handler: async (c, { slug }) => {
      const doc = await c.db
        .query('blog')
        .withIndex('by_slug', q => q.eq('slug', slug))
        .unique()
      return doc ? (await c.withAuthor([doc]))[0] : null
    }
  }),
  archive = m({
    args: { id: z.string() },
    handler: async (c, { id }) => c.patch(id, { published: false })
  })

Frontend

import { useQuery } from 'convex/react'
import { useList } from 'noboil/convex/react'

const Dashboard = () => {
  const stats = useQuery(api.blog.stats, { category: 'tech' })
  const { items } = useList(api.blog.list)

  return (
    <>
      <p>
        {stats?.published} published / {stats?.draft} drafts
      </p>
      <ul>
        {items.map(b => (
          <li key={b._id}>{b.title}</li>
        ))}
      </ul>
    </>
  )
}

Bulk Operations with Progress

Features: useBulkMutate · toast feedback · progress tracking · error collection

useBulkMutate is available from both noboil/convex/react and noboil/spacetimedb/react. The Convex version is shown below; on SpacetimeDB, pass useReducer(reducers.rm_blog) instead of a Convex mutation.

Uses rm from any crud() call — pass { ids: [...] } for bulk deletion. No extra backend code needed.

Frontend

import { useMutation } from 'convex/react'
import { useBulkMutate } from 'noboil/convex/react'

const BulkActions = ({ selectedIds }: { selectedIds: string[] }) => {
  const rm = useMutation(api.blog.rm)
  const { isPending, progress, run } = useBulkMutate(
    (id: string) => rm({ id }),
    {
      onProgress: p => console.log(`${p.succeeded}/${p.total}`),
      toast: {
        error: 'Some items failed to delete',
        loading: p => `Deleting ${p.succeeded}/${p.total}...`,
        success: n => `Deleted ${n} items`
      }
    }
  )

  return (
    <button
      disabled={isPending}
      onClick={() => {
        run(selectedIds)
      }}
    >
      {progress
        ? `${progress.succeeded}/${progress.total}`
        : `Delete ${selectedIds.length}`}
    </button>
  )
}

useBulkMutate fires all mutations concurrently, tracks per-item success/failure, and returns { errors, results, settled } when done. Use onError: false to silence the default error toast. Use onSettled for cleanup that runs regardless of outcome.


Ownership Flags with useOwnRows

Features: useOwnRows · per-row ownership · conditional UI

useOwnRows is exported from both noboil/convex/react and noboil/spacetimedb/react. See the Data Fetching page for the SpacetimeDB variant which compares userId against the connected Identity. The Convex version is shown below.

Frontend

import { useQuery } from 'convex/react'
import { useList, useOwnRows } from 'noboil/convex/react'

const BlogList = () => {
  const me = useQuery(api.users.viewer)
  const { items } = useList(api.blog.pub.list)
  const blogs = useOwnRows(items, me ? b => b.userId === me._id : null)

  return (
    <ul>
      {blogs.map(b => (
        <li key={b._id}>
          {b.title}
          {b.own && <span>yours</span>}
        </li>
      ))}
    </ul>
  )
}

useOwnRows annotates each row with own: boolean using a memoized predicate. Pass null when the user is unauthenticated and all rows get own: false. Use the own flag to conditionally render edit/delete buttons without extra queries.


Post-Mutation Workflows

Features: onSuccess · onSettled · redirect · reset form state · toast

Use onSuccess and onSettled callbacks on any Convex mutation:

import { useMutation } from 'convex/react'

const CreatePost = () => {
  const router = useRouter()
  const create = useMutation(api.post.create)

  const handleSubmit = async (data: PostCreate) => {
    await create(data)
    router.push('/posts')
  }

  return <form onSubmit={...}>{...}</form>
}
'use client'

import { useMut } from 'noboil/spacetimedb/react'
import { useRouter } from 'next/navigation'
import { reducers } from '@/generated/module_bindings'

const CreatePostForm = () => {
  const router = useRouter()

  const save = useMut(reducers.create_post, {
    onSuccess: (_result, args) => {
      router.push('/posts')
    },
    onSettled: (_args, error) => {
      setSubmitting(false)
      if (error) console.error('Create failed:', error)
    }
  })

  return (
    <form onSubmit={async e => {
      e.preventDefault()
      const form = new FormData(e.currentTarget)
      await save({
        title: form.get('title') as string,
        content: form.get('content') as string,
        published: false
      })
    }}>
      <input name="title" required />
      <textarea name="content" required />
      <button type="submit">Create</button>
    </form>
  )
}

onSettled fires whether the mutation succeeds or fails. Use it for cleanup that must always happen (clearing spinners, resetting flags). Use onSuccess for actions that only make sense on success (redirects, toasts).


Typing Components with InferRow, InferCreate, InferUpdate

Available for SpacetimeDB only. Convex users derive types directly from Zod schemas using z.infer.

Derive prop types from your schema brands instead of duplicating type definitions.

import type { InferRow, InferCreate, InferUpdate } from 'noboil/spacetimedb/server'
import { schema } from 'noboil/spacetimedb/schema'
import { boolean, object, string } from 'zod/v4'

const s = schema({
  owned: {
    post: object({
      title: string(),
      content: string(),
      published: boolean()
    })
  }
})

type PostRow = InferRow<typeof s.post>
type PostCreate = InferCreate<typeof s.post>
type PostUpdate = InferUpdate<typeof s.post>

const PostCard = ({ post }: { post: PostRow }) => (
  <div>
    <h2>{post.title}</h2>
    <p>{post.content}</p>
    <span>{post.published ? 'Published' : 'Draft'}</span>
  </div>
)

const CreatePostForm = ({ onSubmit }: { onSubmit: (data: PostCreate) => void }) => {
  return null
}

const EditPostForm = ({ post, onSubmit }: { post: PostRow; onSubmit: (data: PostUpdate) => void }) => {
  return null
}

InferRow includes the database-added fields (id, updatedAt, userId for owned schemas). InferCreate is the raw field shape you pass to the create reducer. InferUpdate makes all fields optional.


Phantom Type Inference

Available for SpacetimeDB only. Branded schemas expose $inferRow, $inferCreate, and $inferUpdate as readable properties. No import needed.

import { schema } from 'noboil/spacetimedb/schema'
import { boolean, object, string } from 'zod/v4'

const s = schema({
  owned: {
    post: object({
      title: string(),
      content: string(),
      published: boolean()
    })
  }
})

type PostRow = typeof s.post.$inferRow
type PostCreate = typeof s.post.$inferCreate
type PostUpdate = typeof s.post.$inferUpdate

PostRow includes the database-added fields. This is equivalent to InferRow<typeof s.post> but skips the import.

The ~types accessor groups all three:

type PostTypes = (typeof s.post)['~types']
type PostRow = PostTypes['row']
type PostCreate = PostTypes['create']
type PostUpdate = PostTypes['update']

Use these in component props to stay in sync with the schema:

const PostCard = ({ post }: { post: typeof s.post.$inferRow }) => (
  <div>
    <h2>{post.title}</h2>
    <p>{post.content}</p>
  </div>
)

Global Error Type with Register

Available for SpacetimeDB only. Use declaration merging to set a project-wide default error type. All noboil/spacetimedb hooks and utilities will use your type instead of the default Error.

declare module 'noboil/spacetimedb/server' {
  interface Register {
    defaultError: AppError
  }
}

interface AppError {
  code: string
  message: string
  requestId?: string
}

After augmenting Register, RegisteredDefaultError resolves to AppError:

import type { RegisteredDefaultError } from 'noboil/spacetimedb/server'

const handleError = (error: RegisteredDefaultError) => {
  console.error(`[${error.requestId}] ${error.code}: ${error.message}`)
}

Create and Update Forms with schemaVariants

Available for SpacetimeDB only. Convex users can use Zod's .partial() directly or pickValues from noboil/convex/zod.

Define one base schema and derive both create and update variants from it.

import { schemaVariants } from 'noboil/spacetimedb/zod'
import { boolean, object, string } from 'zod/v4'

const postSchema = object({
  title: string().min(1, 'Title is required'),
  content: string().min(10, 'Content must be at least 10 characters'),
  published: boolean()
})

const { create: createSchema, update: updateSchema } = schemaVariants(postSchema, ['title'])

const CreatePost = () => {
  const createPost = useReducer(reducers.create_post)
  const form = useForm({
    schema: createSchema,
    onSubmit: async ({ value }) => {
      await createPost(value)
    }
  })
  return (
    <Form form={form}>
      <fields.Text name="title" required />
      <fields.Text name="content" multiline required />
      <fields.Toggle name="published" trueLabel="Published" />
      <fields.Submit>Create</fields.Submit>
    </Form>
  )
}

const EditPost = ({ post }: { post: PostRow }) => {
  const updatePost = useReducer(reducers.update_post)
  const form = useForm({
    schema: updateSchema,
    defaultValues: { title: post.title, content: post.content, published: post.published },
    onSubmit: async ({ value }) => {
      await updatePost({ id: post.id, ...value })
    }
  })
  return (
    <Form form={form}>
      <fields.Text name="title" required />
      <fields.Text name="content" multiline />
      <fields.Toggle name="published" trueLabel="Published" />
      <fields.Submit>Save</fields.Submit>
    </Form>
  )
}

Typed Form Validation Errors with getFieldErrors

Available for SpacetimeDB only. Surface server-side field validation errors back into your form UI.

'use client'

import { getFieldErrors } from 'noboil/spacetimedb/server'
import { useMut } from 'noboil/spacetimedb/react'
import { useState } from 'react'
import { reducers } from '@/generated/module_bindings'
import { z } from 'zod/v4'

const postSchema = z.object({
  title: z.string().min(1),
  content: z.string().min(10),
  published: z.boolean()
})

const CreatePost = () => {
  const [fieldErrors, setFieldErrors] = useState<Partial<{ title: string; content: string }>>({})

  const save = useMut(reducers.create_post, {
    onError: error => {
      const errors = getFieldErrors<typeof postSchema>(error)
      if (errors) {
        setFieldErrors(errors)
        return
      }
    }
  })

  return (
    <form onSubmit={async e => {
      e.preventDefault()
      setFieldErrors({})
      const form = new FormData(e.currentTarget)
      await save({
        title: form.get('title') as string,
        content: form.get('content') as string,
        published: false
      })
    }}>
      <div>
        <input name="title" />
        {fieldErrors.title && <p className="text-red-500">{fieldErrors.title}</p>}
      </div>
      <div>
        <textarea name="content" />
        {fieldErrors.content && <p className="text-red-500">{fieldErrors.content}</p>}
      </div>
      <button type="submit">Create</button>
    </form>
  )
}

getFieldErrors returns undefined when the error has no field-level data, so you can safely fall through to a generic error handler.


Reducing Mutation Boilerplate with useMutation

Available for SpacetimeDB only. useMutation combines useReducer + useMutate into one call.

Before:

import { useMutate } from 'noboil/spacetimedb/react'
import { useReducer } from 'spacetimedb/react'
import { reducers } from '@/generated/module_bindings'

const raw = useReducer(reducers.update_blog)
const save = useMutate(raw, { onSuccess: () => toast.success('Saved') })

With useMutation:

import { useMutation } from 'noboil/spacetimedb/react'
import { useReducer } from 'spacetimedb/react'
import { reducers } from '@/generated/module_bindings'

const save = useMutation(useReducer, reducers.update_blog, {
  onSuccess: () => toast.success('Saved')
})

With useMut (simplest):

import { useMut } from 'noboil/spacetimedb/react'
import { reducers } from '@/generated/module_bindings'

const save = useMut(reducers.update_blog, {
  onSuccess: () => toast.success('Saved')
})

All MutateOptions work the same way: onSuccess, onSettled, onError, retry, optimistic, etc.


Toast Shorthand with useMutation

Available for SpacetimeDB only. The toast option replaces manual onSuccess/onError callbacks when all you need is a message.

Before:

import { useMutation } from 'noboil/spacetimedb/react'
import { useReducer } from 'spacetimedb/react'
import { reducers } from '@/generated/module_bindings'

const save = useMutation(useReducer, reducers.update_blog, {
  onSuccess: () => toast.success('Saved'),
  onError: () => toast.error('Save failed')
})

After, with dynamic messages:

const save = useMutation(useReducer, reducers.update_blog, {
  toast: {
    success: (result, args) => `"${args.title}" saved`,
    error: err =>
      `Save failed: ${err instanceof Error ? err.message : 'unknown'}`
  }
})

fieldErrors defaults to true. If the server returns field validation errors, the first one is toasted before the generic error message. Set fieldErrors: false to skip that behavior.

onSuccess and toast.success compose; both run when provided.

useFormMutation also accepts toast: { success?, error? } with the same composition rules:

const form = useFormMutation({
  schema: blogSchema,
  mutate: useReducer(reducers.createBlog),
  toast: { success: 'Created' },
  onSuccess: () => router.push('/posts')
})

Field Validation Error Toasts with toastFieldError

Available for SpacetimeDB only. Use toastFieldError to surface the first field validation error as a toast, then fall back to a generic message for non-field errors.

'use client'

import { toastFieldError } from 'noboil/spacetimedb/react'
import { useMutation } from 'noboil/spacetimedb/react'
import { useReducer } from 'spacetimedb/react'
import { reducers } from '@/generated/module_bindings'

const BlogEditor = () => {
  const save = useMutation(useReducer, reducers.update_blog, {
    onError: error => {
      if (!toastFieldError(error, toast.error)) {
        toast.error('Something went wrong')
      }
    }
  })

  const handleSubmit = async (data: { id: number; title: string; content: string }) => {
    await save(data)
  }

  return <form onSubmit={...}>{...}</form>
}

toastFieldError returns true when it toasted a field error, so the if block only runs for other error types. Pair it with getFieldErrors when you need to display errors inline in the form rather than as toasts.


Error Discrimination with SenderError._tag

Available for SpacetimeDB only. SenderError carries _tag: 'SenderError' as a const property. Use it to narrow errors in catch blocks when you need to distinguish noboil/spacetimedb reducer errors from other thrown values.

import { extractErrorData } from 'noboil/spacetimedb/server'

try {
  await save(data)
} catch (e) {
  if (e instanceof Error && '_tag' in e && e._tag === 'SenderError') {
    const data = extractErrorData(e)
    if (data?.code === 'CONFLICT') {
      showConflictDialog(data)
      return
    }
  }
  throw e
}

For most cases, handleError or matchError is simpler. They parse the error internally without the _tag check. Use _tag when you need to re-throw non-noboil/spacetimedb errors or integrate with an external error boundary that inspects error shape.

AutoForm — Zero-Layout Forms

AutoForm auto-renders all fields from your schema. No manual field layout needed.

import { AutoForm, useFormMutation } from 'noboil/convex/components'

const CreateBlog = () => {
  const form = useFormMutation({
    mutation: api.blog.create,
    schema: s.blog
  })
  return <AutoForm form={form} submitLabel='Create Blog' />
}

All field types are auto-detected from the schema: strings render as Text, booleans as Toggle, files as File, arrays as Arr, etc. Use exclude to hide specific fields:

<AutoForm form={form} exclude={['published']} submitLabel='Save Draft' />

For full control over field layout, use Form with the render prop instead:

<Form form={form} render={f => (
  <>
    <f.Text name='title' required />
    <f.Text name='content' multiline />
    <f.Submit>Create</f.Submit>
  </>
)} />

File Constraints in Schema

Specify accept and maxSize directly in the schema — they auto-apply to form fields:

import { file, files } from 'noboil/convex/schema'

const s = schema({
  owned: {
    post: object({
      title: string().min(1),
      cover: file({ accept: 'image/*', maxSize: 5 * 1024 * 1024 }).nullable().optional(),
      attachments: files({ accept: 'image/*,application/pdf', maxSize: 10 * 1024 * 1024 }).optional()
    })
  }
})

No need to pass accept or maxSize props to <f.File> or <f.Files> — they read from schema meta automatically. Props still override if needed.

Auto Conflict Detection

Pass doc to useFormMutation to auto-inject expectedUpdatedAt:

const form = useFormMutation({
  doc: blog,
  mutation: api.blog.update,
  schema: s.blog,
  transform: d => ({ ...d, id: blog._id }),
  values: pickValues(s.blog, blog)
})

If another user edits the same document, the server returns CONFLICT and the ConflictDialog lets the user choose: cancel, reload, or overwrite.

Custom Error Codes

ErrorCode accepts any string while keeping autocomplete for built-in codes:

import { err } from 'noboil/convex/server'

err('EMAIL_TAKEN', { message: 'This email is already registered' })

Handle custom codes with matchError:

import { matchError } from 'noboil/convex/server'

matchError(error, {
  EMAIL_TAKEN: data => toast.error(data.message),
  VALIDATION_FAILED: data => showFieldErrors(data.fieldErrors),
  default: () => toast.error('Something went wrong')
})

Unified CRUD with useCrud + createApi

createApi builds typed refs from your backend exports. useCrud consumes them — same code on both backends:

// Convex — create api once
import { createApi } from 'noboil/convex/react'
import { api as raw } from '@a/be-convex'

export const api = createApi({ blog: raw.blog, chat: raw.chat })
// SpacetimeDB — create api once
import { createApi } from 'noboil/spacetimedb/react'
import { reducers, tables } from '@a/be-spacetimedb/spacetimedb'

export const api = createApi(tables, reducers)

Then in any component — identical on both backends:

import { useCrud } from 'noboil/convex/react' // or noboil/spacetimedb/react

const BlogList = () => {
  const { data, create, update, rm, hasMore, loadMore, isLoading } = useCrud(api.blog)
  return <>{data.map(b => <div key={b._id}>{b.title}</div>)}</>
}

useCrud(api.blog) returns { data, create, update, rm, hasMore, isLoading, loadMore } on both backends. The only backend-specific code is the one-time createApi call.

You can also pass refs directly without createApi:

// Convex — inline refs
useCrud({
  create: api.blog.create,
  list: api.blog.pub.list,
  rm: api.blog.rm,
  update: api.blog.update
})

// SpacetimeDB — inline refs
useCrud({
  create: reducers.createBlog,
  rm: reducers.rmBlog,
  table: tables.blog,
  update: reducers.updateBlog
})

Both return the same { data, create, update, rm, hasMore, isLoading, loadMore }. The only difference is refs: Convex passes api.* functions, SpacetimeDB passes reducers.* + tables.*.

Poll: kv banner + log votes + quota anti-spam

A working poll page that composes three new factories: a kv siteConfig.banner row read by every visitor, a log vote table for ballots, and a quota pollVote limit gating writes per ${userId}:${pollId}. See the full source in web/cvx/poll.

// backend/convex/s.ts
const s = schema({
  owned: { poll: object({ options: array(string()).min(2).max(10), question: string() }) },
  log: { vote: { parent: 'poll', schema: object({ optionIdx: number(), voter: string() }) } },
  kv: { siteConfig: { keys: ['banner'] as const, schema: object({ active: boolean(), message: string() }), writeRole: true } },
  quota: { pollVote: { durationMs: 60_000, limit: 30 } }
})
// backend/convex/lazy.ts
tables: ({ table }) => ({
  poll: table(s.poll),
  vote: table(s.vote, { softDelete: true }),
  siteConfig: table(s.siteConfig, { softDelete: true }),
  pollVoteQuota: table(s.pollVote)
})
// web/cvx/poll/src/app/page.tsx
const VoteView = ({ pollId, options }: { pollId: string; options: string[] }) => {
  const log = useLog(api.vote, { parent: pollId })
  const quota = useQuota(api.pollVoteQuota, pollId)
  const counts = options.map((_, i) => log.data.filter(v => v.optionIdx === i).length)
  const vote = async (i: number) => {
    const r = await quota.consume()
    if (!r.allowed) return toast.error(`retry in ${r.retryAfter}ms`)
    await log.append({ payload: { optionIdx: i, voter: 'me' } })
  }
  return options.map((opt, i) => (
    <Button key={opt} disabled={!quota.state?.allowed} onClick={() => vote(i)}>
      {opt} ({counts[i]})
    </Button>
  ))
}
const BannerDisplay = () => {
  const banner = useKv(api.siteConfig, 'banner')
  return banner.data?.active ? <Alert>{banner.data.message}</Alert> : null
}

This composes:

  • log for append-only votes with atomic per-poll seq + soft-delete + restore
  • kv for a publicly-readable site banner with role-gated writes
  • quota as the gate before each log.append (anti-ballot-stuffing)

End-to-end coverage: 82 Playwright tests (web/cvx/poll/e2e/*) exercise this exact flow including bulk append, purge/restore, banner restore, quota exhaustion, and the per-poll detail/edit routes.

JSDoc @example library

Every @example JSDoc block harvested from lib/noboil/src/. These are tested-by-living-source examples — the surrounding code must compile.

32 @example blocks harvested from JSDoc across lib/noboil/src/.

asDblib/noboil/src/convex/server/setup.ts

const { crud, orgCrud, pq, q, m } = setup({
  query, mutation, action, internalQuery, internalMutation, getAuthUserId
})

// Then generate endpoints:
export const { create, update, rm, pub: { list, read } } = crud('blog', owned.blog)

AuditExportslib/noboil/src/convex/server/audit.ts

// convex/audit.ts
export const { append, recent, listByActor, listByTrace, pruneStale } = makeAudit({
  builders, table: 'audit', options: { ttlMs: 30 * 24 * 60 * 60 * 1000 }
})

BudgetExportslib/noboil/src/convex/server/budget.ts

// convex/llmBudget.ts
export const { reserve, settle, check, add, pruneStale, auditInvariants } = makeBudget({
  builders, table: 'llmBudget', cap: 10_000, inflightMax: 50, periodMs: 24 * 60 * 60 * 1000
})

CacheOptionslib/noboil/src/convex/server/types.ts

makeCacheCrud({ builders, schema: schemas.movie, table: 'movie', options: {
  key: 'tmdbId',
  fetcher: async (_, id) => fetchMovieFromTmdb(Number(id)),
  ttl: 24 * 60 * 60 * 1000,
  staleWhileRevalidate: true
} })

CrudConfiglib/noboil/src/spacetimedb/server/types/crud.ts

makeCrud(spacetimedb, {
  tableName: 'todo',
  fields: { done: t.boolean(), title: t.string() },
  idField: t.u32(),
  pk: tbl => tbl.id,
  table: db => db.todo,
  options: { rateLimit: { max: 30, window: 60_000 }, softDelete: true }
})

CrudHookslib/noboil/src/convex/server/types.ts

makeCrud({ schema, table: 'todo', options: { hooks: {
  beforeCreate: (ctx, { data }) => ({ ...data, ownerId: ctx.userId }),
  afterDelete: async (ctx, { id }) => { await ctx.db.delete(id) }
} } })
Typechecked usage: `lib/noboil/examples/convex/make-crud.example.ts`.

CrudOptionslib/noboil/src/convex/server/types.ts

makeCrud({ schema: schemas.todo, table: 'todo', options: {
  pub: 'isPublished',
  softDelete: true,
  rateLimit: { max: 30, window: 60_000 },
  search: 'title'
} })

CrudResultlib/noboil/src/convex/server/types.ts

// convex/todo.ts
import { makeCrud } from 'noboil/convex/server'
import { schemas } from './schema'
export const { auth, pub, authIndexed, create, update, rm } = makeCrud({
  builders, schema: schemas.todo, table: 'todo', options: { pub: true }
})

ErrorDatalib/noboil/src/shared/server/helpers.ts

interface ComparisonOp<V> {
  $between?: [V, V]
  $gt?: V
  $gte?: V
  $lt?: V
  $lte?: V
}
/** Structured error payload thrown by `err()` and inspected via `extractErrorData()` / `getErrorCode()`.

FileUploadExportslib/noboil/src/spacetimedb/server/types/file.ts

makeFileUpload(spacetimedb, {
  namespace: 'avatars',
  fields: { contentType: t.string(), data: t.array(t.u8()), filename: t.string(), size: t.u32() },
  idField: t.u32(),
  pk: tbl => tbl.id,
  table: db => db.file,
  maxFileSize: 5 * 1024 * 1024
})

KvExportslib/noboil/src/spacetimedb/server/kv.ts

makeKv(spacetimedb, {
  tableName: 'siteConfig',
  keyField: t.string(),
  fields: { value: t.string() },
  table: db => db.siteConfig,
  options: { softDelete: true }
})

KvSchemalib/noboil/src/convex/server/types.ts

// convex/siteConfig.ts
import { makeKv } from 'noboil/convex/server'
import { schemas } from './schema'
export const { get, list, set, rm, restore } = makeKv({
  builders, schema: schemas.siteConfig.banner, table: 'siteConfig',
  keys: ['banner', 'maintenanceMode'] as const, softDelete: true
})

LogExportslib/noboil/src/spacetimedb/server/log.ts

makeLog(spacetimedb, {
  tableName: 'message',
  parentField: t.string(),
  idempotencyKeyField: t.string(),
  fields: { text: t.string() },
  table: db => db.message,
  options: { rateLimit: { max: 30, window: 60_000 } }
})

LogSchemalib/noboil/src/convex/server/types.ts

// convex/message.ts
export const { append, list, listAfter, purgeByParent } = makeLog({
  builders, schema: schemas.message, table: 'message',
  options: { rateLimit: 30, search: 'text' }
})

makeCrudlib/noboil/src/spacetimedb/server/crud.ts

const reducers = makeCrud(spacetimedb, { tableName: 'post', fields, idField, pk, table })

makeFileUploadlib/noboil/src/spacetimedb/server/file.ts

const uploads = makeFileUpload(spacetimedb, { namespace: 'avatars', fields, idField, pk, table })

noboillib/noboil/src/convex/server/noboil.ts

import { noboil } from './'
import { action, internalMutation, internalQuery, mutation, query } from './_generated/server'
import { getAuthUserId } from '@convex-dev/auth/server'
import { s } from './s'

export const api = noboil({
  query, mutation, action, internalQuery, internalMutation, getAuthUserId,
  orgSchema: s.team,
  tables: ({ table }) => ({
    blog: table(s.blog, { rateLimit: 10, search: 'content' }),
    wiki: table(s.wiki, { acl: true, softDelete: true }),
    profile: table(s.profile),
    movie: table(s.movie, { key: 'tmdbId', ttl: 86_400 })
  })
})

// Then in convex/blog.ts:
import { api } from './lazy'
export const { create, update, rm, pub: { list, read, search } } = api.blog

OrgCrudExportslib/noboil/src/spacetimedb/server/types/org-crud.ts

makeOrgCrud(spacetimedb, {
  tableName: 'project',
  fields: { name: t.string() },
  idField: t.u32(),
  pk: tbl => tbl.id,
  table: db => db.project,
  orgTable: db => db.org,
  memberTable: db => db.orgMember,
  options: { acl: true, softDelete: true }
})

PaginationOptsShapelib/noboil/src/convex/server/types.ts

// convex/project.ts
import { makeOrgCrud } from 'noboil/convex/server'
import { schemas } from './schema'
export const { create, list, read, update, rm, addEditor, removeEditor, editors } = makeOrgCrud({
  builders, schema: schemas.project, table: 'project', options: { acl: true, softDelete: true }
})

QuotaEntrylib/noboil/src/convex/server/types.ts

// convex/pollVoteQuota.ts
export const { check, consume, record } = makeQuota({
  builders, schema: schemas.pollVoteQuota, table: 'pollVoteQuota',
  options: { durationMs: 60_000, limit: 10 }
})

QuotaExportslib/noboil/src/spacetimedb/server/quota.ts

makeQuota(spacetimedb, {
  tableName: 'pollVoteQuota',
  ownerField: t.string(),
  table: db => db.pollVoteQuota,
  durationMs: 60_000,
  limit: 10
})

resolveToastErrorlib/noboil/src/spacetimedb/react/use-mutate.ts

const save = useMutate(api.posts.update, { optimistic: true })

SetupConfiglib/noboil/src/convex/server/types.ts

// convex/_setup.ts
import { setup } from 'noboil/convex/server'
import { mutation, query, action, internalMutation, internalQuery } from './_generated/server'
import { auth } from './auth'
export const { m, q, cm, cq, action: a, ... } = setup({
  action, mutation, query, internalMutation, internalQuery,
  getAuthUserId: ctx => auth.getUserId(ctx),
  middleware: [composeMiddleware(auditLog, slowQueryWarn)]
})

SingletonExportslib/noboil/src/spacetimedb/server/types/singleton.ts

makeSingletonCrud(spacetimedb, {
  tableName: 'profile',
  fields: { bio: t.string().optional(), name: t.string().optional() },
  table: db => db.profile
})

SKIP_RESULTlib/noboil/src/spacetimedb/react/use-infinite-list.ts

const list = useInfiniteList(rows, ready, { batchSize: 25 })

SKIP_RESULTlib/noboil/src/spacetimedb/react/use-list.ts

const list = useList(rows, ready, {
  pageSize: 20,
  where: { own: true },
  search: { query: 'hello', fields: ['title', 'content'] }
})

useListlib/noboil/src/convex/react/use-list.ts

const { data, loadMore, isDone } = useList(api.blog.list, { where: { published: true } })

useMutatelib/noboil/src/convex/react/use-mutate.ts

const update = useMutate(api.blog.update)
const remove = useMutate(api.blog.rm, { onError: false })

useOrgQuerylib/noboil/src/convex/react/org.tsx

const wikis = useOrgQuery(api.wiki.list)

useOwnRowslib/noboil/src/spacetimedb/react/use-list.ts

const blogs = useOwnRows(allBlogs, identity ? b => b.userId.isEqual(identity) : null)

usePresencelib/noboil/src/convex/react/use-presence.ts

const { users, updatePresence } = usePresence(presenceRefs, chatId, { data: { cursor: { x, y } } })

zodFromTablelib/noboil/src/spacetimedb/stdb-zod.ts

const schema = zodFromTable(module.table.columns, { optional: ['bio'] })

On this page

Recipes indexBlog with Auth, File Upload, Pagination, and SearchSchemaBackendFrontendSchemaBackend moduleFrontendOrg CRUD with ACL and Cascade DeleteSchemaBackendFrontendSchemaClientCache with External APISchemaBackendFrontendSchemaNext.js API route for cache populationClientSoft Delete with RestoreSchemaClientReal-Time Presence TrackingSchemaBackendFrontendSchemaFrontendReal-Time ChatSchemaChat componentFile UploadDisplaying uploaded files after reloadMulti-Step Onboarding FormSchemaBackendFrontendSingleton Profile with File UploadSchemaBackendFrontendCustom Queries Alongside CRUD (Convex)BackendFrontendBulk Operations with ProgressFrontendOwnership Flags with useOwnRowsFrontendPost-Mutation WorkflowsTyping Components with InferRow, InferCreate, InferUpdatePhantom Type InferenceGlobal Error Type with RegisterCreate and Update Forms with schemaVariantsTyped Form Validation Errors with getFieldErrorsReducing Mutation Boilerplate with useMutationToast Shorthand with useMutationField Validation Error Toasts with toastFieldErrorError Discrimination with SenderError._tagAutoForm — Zero-Layout FormsFile Constraints in SchemaAuto Conflict DetectionCustom Error CodesUnified CRUD with useCrud + createApiPoll: kv banner + log votes + quota anti-spamJSDoc @example libraryasDblib/noboil/src/convex/server/setup.tsAuditExportslib/noboil/src/convex/server/audit.tsBudgetExportslib/noboil/src/convex/server/budget.tsCacheOptionslib/noboil/src/convex/server/types.tsCrudConfiglib/noboil/src/spacetimedb/server/types/crud.tsCrudHookslib/noboil/src/convex/server/types.tsCrudOptionslib/noboil/src/convex/server/types.tsCrudResultlib/noboil/src/convex/server/types.tsErrorDatalib/noboil/src/shared/server/helpers.tsFileUploadExportslib/noboil/src/spacetimedb/server/types/file.tsKvExportslib/noboil/src/spacetimedb/server/kv.tsKvSchemalib/noboil/src/convex/server/types.tsLogExportslib/noboil/src/spacetimedb/server/log.tsLogSchemalib/noboil/src/convex/server/types.tsmakeCrudlib/noboil/src/spacetimedb/server/crud.tsmakeFileUploadlib/noboil/src/spacetimedb/server/file.tsnoboillib/noboil/src/convex/server/noboil.tsOrgCrudExportslib/noboil/src/spacetimedb/server/types/org-crud.tsPaginationOptsShapelib/noboil/src/convex/server/types.tsQuotaEntrylib/noboil/src/convex/server/types.tsQuotaExportslib/noboil/src/spacetimedb/server/quota.tsresolveToastErrorlib/noboil/src/spacetimedb/react/use-mutate.tsSetupConfiglib/noboil/src/convex/server/types.tsSingletonExportslib/noboil/src/spacetimedb/server/types/singleton.tsSKIP_RESULTlib/noboil/src/spacetimedb/react/use-infinite-list.tsSKIP_RESULTlib/noboil/src/spacetimedb/react/use-list.tsuseListlib/noboil/src/convex/react/use-list.tsuseMutatelib/noboil/src/convex/react/use-mutate.tsuseOrgQuerylib/noboil/src/convex/react/org.tsxuseOwnRowslib/noboil/src/spacetimedb/react/use-list.tsusePresencelib/noboil/src/convex/react/use-presence.tszodFromTablelib/noboil/src/spacetimedb/stdb-zod.ts