Convex vs SpacetimeDB
Where the noboil/convex and noboil/spacetimedb APIs diverge — and why.
The rest of the docs cover the noboil API as one thing, because almost every component, hook, schema helper, and convention works the same on both backends. The handful of places where the libraries genuinely differ live here.
If you're switching between the two, this page is the only one you need to re-read.
TL;DR — switching backends
Almost every snippet in the docs is the same code on both backends. The only thing you usually have to change is the import path:
// before
import { noboil } from 'noboil/convex/server'
import { useFormMutation, Form } from 'noboil/convex/components'
// after
import { noboil } from 'noboil/spacetimedb/server'
import { useFormMutation, Form } from 'noboil/spacetimedb/components'useForm, useFormMutation, Form, defineSteps, EditorsSection, PermissionGuard, OrgAvatar, RoleBadge, OfflineIndicator, AutoSaveIndicator, ConflictDialog, ErrorBoundary, Devtools, useList, useSearch, useBulkSelection, useBulkMutate, useSoftDelete, useUpload, useOwnRows, useInfiniteList, useOrg, useActiveOrg, useMyOrgs, useOrgMutation, useOrgQuery, OrgProvider, createOrgHooks, usePresence, useOptimisticMutation, singletonCrud results, child(), file(), files(), makeOwned, makeOrgScoped, makeBase, makeSingleton, makeOrg, schema(), noboil() — all symmetric.
Server-side parity matrix
Auto-generated from filesystem presence and source-line counts in lib/noboil/src/{convex,spacetimedb}/server/:
| Feature | Source file | Convex | SpacetimeDB |
|---|---|---|---|
| owned CRUD | crud.ts | ✓ (436 loc) | ✓ (139 loc) |
| org-scoped CRUD | org-crud.ts | ✓ (498 loc) | ✓ (400 loc) |
| child CRUD | child.ts | ✓ (272 loc) | ✓ (163 loc) |
| singleton (1-per-user) | singleton.ts | ✓ (75 loc) | ✓ (113 loc) |
| cache (TTL + refresh) | cache-crud.ts | ✓ (249 loc) | ✓ (173 loc) |
| log (append-only) | log.ts | ✓ (390 loc) | ✓ (272 loc) |
| kv (string-keyed) | kv.ts | ✓ (167 loc) | ✓ (161 loc) |
| quota (sliding window) | quota.ts | ✓ (115 loc) | ✓ (123 loc) |
| org schema (membership + invites) | org.ts | ✓ (297 loc) | ✓ (565 loc) |
| org members API | org-members.ts | ✓ (137 loc) | ✓ (245 loc) |
| org invites API | org-invites.ts | ✓ (106 loc) | ✓ (398 loc) |
| org join requests | org-join.ts | ✓ (141 loc) | ✓ (260 loc) |
| presence | presence.ts | ✓ (87 loc) | ✓ (141 loc) |
| file uploads | file.ts | ✓ (415 loc) | ✓ (147 loc) |
| middleware (audit, sanitize, slow-warn) | middleware.ts | ✓ (63 loc) | ✓ (68 loc) |
The actual differences
1. noboil() config
Convex needs its query/mutation/action/etc. builders and getAuthUserId as the first argument because it has to wire into the Convex framework. SpacetimeDB doesn't.
// Convex
import { noboil } from 'noboil/convex/server'
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' })
})
})
// SpacetimeDB
import { noboil } from 'noboil/spacetimedb/server'
import { s } from '../s'
export default noboil({ tables: ({ table }) => ({
blog: table(s.blog, { pub: 'published', rateLimit: 10 })
}) })The tables callback (({ table }) => ({...})) is identical. Only the outer config object differs.
2. Where filters and the pub option
Both backends accept the same where clause shape ($gt, $gte, $lt, $lte, $between, or, own: true, field equality), but they enforce visibility differently.
| Convex | SpacetimeDB | |
|---|---|---|
| Read endpoint shape | crud(...) returns { pub, auth } — destructure to expose either tier | table(s.blog, { pub: 'published' }) generates a clientVisibilityFilter SQL rule |
pub: 'fieldName' | Default where clause for the pub tier | Generates WHERE fieldName = true OR userId = :sender, indexed automatically |
| Filter execution | .filter() after fetch (good up to ~1,000 docs) — set strictFilter: true to throw past the limit | Server-enforced subscription rule, runs in SpacetimeDB Rust core |
| Custom indexes | pubIndexed / authIndexed query helpers | Add an index on the table; subscriptions use it automatically |
3. File storage
Both libs accept the same file() and files() schema helpers. The upload backend differs:
| Convex | SpacetimeDB | |
|---|---|---|
| Storage | Convex native (_storage IDs) | Inline byte arrays (t.byteArray()) |
| Setup | makeFileUpload() + FileApiProvider | table.file() in noboil() + FileApiProvider |
| Max size | Convex storage limits | ~100MB per file |
| Cleanup on delete | Automatic via cascade hooks | Automatic via delete reducer |
The <File> and <Files> form components from /components work identically — upload, preview, and storage handled automatically.
SpacetimeDB inline files produce blob URLs that expire on reload. Use resolveFileUrl(files, ref) from /react to reconstruct URLs from the subscribed file table. Convex storage URLs are permanent.
4. Custom server logic
Both libs let you write custom queries/mutations alongside generated CRUD, but the building blocks differ.
| Convex | SpacetimeDB | |
|---|---|---|
| Public query | pq({ args, handler }) — no auth required, viewer info available | Custom reducer with no ctx.sender check |
| Auth query | q({ args, handler }) — ctx.user guaranteed | Custom reducer that validates ctx.sender |
| Mutation | m({ args, handler }) — ctx.create/patch/delete with conflict detection | Custom reducer with ctx.db.<table>.insert/update/delete |
| Long-running / external API | action({ ... }) (raw Convex action) | Procedure or scheduled reducer |
| Background jobs | Convex cron / scheduled functions | scheduledReducer: true on a reducer |
5. SSR
| Convex | SpacetimeDB | |
|---|---|---|
| Server Component data | preloadQuery(api.blog.list, args, { token }) after await connection() | HTTP SQL endpoint: POST /v1/database/<module>/sql with raw SQL |
| Latency | RPC roundtrip | ~0.27ms local Docker, varies on Maincloud |
| Setup | convexAuthNextjsToken() from @convex-dev/auth/nextjs/server | Plain fetch to the SQL endpoint |
6. Bulk operations
Both libs support bulk create / update / delete via useBulkMutate on the client. On the server side:
- Convex:
crud()accepts a single call withitems: [...](create) orids: [...](delete). Capped atBULK_MAX = 100per call, enforced server-side. - SpacetimeDB: reducers are fire-and-forget. The client loops and the reducer processes one item per call.
useBulkMutatehandles the loop.
7. Auth provider
| Convex | SpacetimeDB | |
|---|---|---|
| Identity source | @convex-dev/auth (getAuthUserId(ctx) returns user _id) | SpacetimeDB cryptographic Identity injected by the runtime as ctx.sender |
| Login providers | OAuth (Google, GitHub, etc), email, password — configured per Convex deployment | Anonymous by default; OIDC providers on Maincloud, custom JWT for self-hosted |
| Test mode | getAuthUserIdOrTest swap when CONVEX_TEST_MODE=true | connectAsTestUser(name) reuses the same Identity by saving the token |
8. Conflict detection
Both backends accept expectedUpdatedAt on update calls and throw CONFLICT when the row was modified since the client read it. The error code and the built-in ConflictDialog UI work the same way. The only difference is the wire format of the error envelope, which extractErrorData() normalizes.
9. Cache tables
Both libs support a base schema brand for cache tables. Convex's cacheCrud() accepts a fetcher function for transparent load-on-miss. SpacetimeDB doesn't run external fetch() reliably from inside the module, so the SpacetimeDB pattern is to populate cache tables from a Next.js API route or a scheduled reducer.
Things that look like differences but aren't
These trip up new users — they look DB-specific but they aren't:
file()/files()— same name, same schema, both libsschema({...})— same call shape, same slot names (owned,orgScoped,base,singleton,org,children)- Org system (invites, join requests, roles, ACL with
editors[]) — identical API and identical reducer/endpoint names - Presence — same
usePresencehook, same heartbeat shape - Soft delete — same
softDelete: truetable option, samerestoreendpoint, sameuseSoftDeletehook - Rate limiting — both accept
rateLimit: { max, window }orrateLimit: 10shorthand (10 requests per minute) - Error codes — both libs use the same
ErrorCodeenum (NOT_AUTHENTICATED,NOT_FOUND,FORBIDDEN,CONFLICT,RATE_LIMITED,VALIDATION_FAILED, etc.) andextractErrorData()returns the same shape
If a doc page has <Tabs> between Convex and SpacetimeDB, it's because the snippet differs by something on this page. Otherwise the snippet is shown once and works on both.