noboil

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/:

FeatureSource fileConvexSpacetimeDB
owned CRUDcrud.ts✓ (436 loc)✓ (139 loc)
org-scoped CRUDorg-crud.ts✓ (498 loc)✓ (400 loc)
child CRUDchild.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 APIorg-members.ts✓ (137 loc)✓ (245 loc)
org invites APIorg-invites.ts✓ (106 loc)✓ (398 loc)
org join requestsorg-join.ts✓ (141 loc)✓ (260 loc)
presencepresence.ts✓ (87 loc)✓ (141 loc)
file uploadsfile.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.

ConvexSpacetimeDB
Read endpoint shapecrud(...) returns { pub, auth } — destructure to expose either tiertable(s.blog, { pub: 'published' }) generates a clientVisibilityFilter SQL rule
pub: 'fieldName'Default where clause for the pub tierGenerates 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 limitServer-enforced subscription rule, runs in SpacetimeDB Rust core
Custom indexespubIndexed / authIndexed query helpersAdd 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:

ConvexSpacetimeDB
StorageConvex native (_storage IDs)Inline byte arrays (t.byteArray())
SetupmakeFileUpload() + FileApiProvidertable.file() in noboil() + FileApiProvider
Max sizeConvex storage limits~100MB per file
Cleanup on deleteAutomatic via cascade hooksAutomatic 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.

ConvexSpacetimeDB
Public querypq({ args, handler }) — no auth required, viewer info availableCustom reducer with no ctx.sender check
Auth queryq({ args, handler })ctx.user guaranteedCustom reducer that validates ctx.sender
Mutationm({ args, handler })ctx.create/patch/delete with conflict detectionCustom reducer with ctx.db.<table>.insert/update/delete
Long-running / external APIaction({ ... }) (raw Convex action)Procedure or scheduled reducer
Background jobsConvex cron / scheduled functionsscheduledReducer: true on a reducer

5. SSR

ConvexSpacetimeDB
Server Component datapreloadQuery(api.blog.list, args, { token }) after await connection()HTTP SQL endpoint: POST /v1/database/<module>/sql with raw SQL
LatencyRPC roundtrip~0.27ms local Docker, varies on Maincloud
SetupconvexAuthNextjsToken() from @convex-dev/auth/nextjs/serverPlain 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 with items: [...] (create) or ids: [...] (delete). Capped at BULK_MAX = 100 per call, enforced server-side.
  • SpacetimeDB: reducers are fire-and-forget. The client loops and the reducer processes one item per call. useBulkMutate handles the loop.

7. Auth provider

ConvexSpacetimeDB
Identity source@convex-dev/auth (getAuthUserId(ctx) returns user _id)SpacetimeDB cryptographic Identity injected by the runtime as ctx.sender
Login providersOAuth (Google, GitHub, etc), email, password — configured per Convex deploymentAnonymous by default; OIDC providers on Maincloud, custom JWT for self-hosted
Test modegetAuthUserIdOrTest swap when CONVEX_TEST_MODE=trueconnectAsTestUser(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 libs
  • schema({...}) — 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 usePresence hook, same heartbeat shape
  • Soft delete — same softDelete: true table option, same restore endpoint, same useSoftDelete hook
  • Rate limiting — both accept rateLimit: { max, window } or rateLimit: 10 shorthand (10 requests per minute)
  • Error codes — both libs use the same ErrorCode enum (NOT_AUTHENTICATED, NOT_FOUND, FORBIDDEN, CONFLICT, RATE_LIMITED, VALIDATION_FAILED, etc.) and extractErrorData() 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.

On this page