Migration
Adopt noboil incrementally — one table at a time, without touching existing code.
No big bang required. Adopt noboil one table at a time while keeping your existing code untouched.
Step 1: Install
bun add noboilPeer dependencies: convex, convex-helpers, zod, @tanstack/react-form, react.
Step 2: Define One Schema
Pick your simplest user-owned table. Define a Zod schema with makeOwned:
import { makeOwned } from 'noboil/convex/schema'
import { boolean, object, string } from 'zod/v4'
const owned = makeOwned({
note: object({
title: string().min(1),
content: string(),
archived: boolean()
})
})Step 3: Register the Table
Add the table alongside your existing schema. ownedTable() returns a standard Convex table definition:
import { defineSchema, defineTable } from 'convex/server'
import { ownedTable } from 'noboil/convex/server'
export default defineSchema({
posts: defineTable({
title: v.string(),
body: v.string(),
userId: v.id('users')
}),
comments: defineTable({ postId: v.id('posts'), text: v.string() }),
note: ownedTable(owned.note)
})Step 4: Setup and Generate Endpoints
Create a setup file (or add to an existing one):
import { setup } from 'noboil/convex/server'
import { getAuthUserId } from '@convex-dev/auth/server'
import {
action,
internalMutation,
internalQuery,
mutation,
query
} from './_generated/server'
const { crud, pq, q, m } = setup({
query,
mutation,
action,
internalQuery,
internalMutation,
getAuthUserId
})Then generate endpoints for your new table:
export const { create, list, read, rm, update } = crud('note', owned.note)Your existing posts and comments endpoints keep working. The new note endpoints live alongside them.
Step 5: Use in React
import { useList } from 'noboil/convex/react'
import { api } from '../convex/_generated/api'
const { items: notes, loadMore, status } = useList(api.note.list)Converting Tables One at a Time
Before (raw Convex)
export const list = query({
args: {},
handler: async ctx => {
const userId = await getAuthUserId(ctx)
if (!userId) throw new Error('Not authenticated')
return ctx.db
.query('posts')
.filter(q => q.eq(q.field('userId'), userId))
.order('desc')
.collect()
}
})
export const create = mutation({
args: { title: v.string(), body: v.string() },
handler: async (ctx, args) => {
const userId = await getAuthUserId(ctx)
if (!userId) throw new Error('Not authenticated')
return ctx.db.insert('posts', { ...args, userId })
}
})After (noboil/convex)
export const { create, list, read, rm, update } = crud('post', owned.post)Coexistence
Both patterns work simultaneously:
convex/
posts.ts ← raw Convex (existing, untouched)
comments.ts ← raw Convex (existing, untouched)
note.ts ← noboil/convex crud()
wiki.ts ← noboil/convex orgCrud()
setup.ts ← noboil/convex setup()Mixing crud() with Custom Endpoints
Generated CRUD covers standard operations. For custom logic, use pq, q, m from setup:
export const { create, list, read, rm, update } = crud('note', owned.note)
export const archive = m({
args: { id: zid('note') },
handler: async (c, { id }) => {
const doc = await c.get(id)
await c.patch(id, { archived: true })
return doc
}
})Both crud() endpoints and custom m() endpoints export from the same file and appear on the same api.note namespace.
Adding Features Incrementally
Start simple, add features as needed:
export const { create, list, read, rm, update } = crud('note', owned.note)
export const { create, list, read, rm, update } = crud('note', owned.note, {
rateLimit: { max: 10, window: 60_000 }
})
export const {
create,
rm,
update,
pub: { list, read, search }
} = crud('note', owned.note, {
rateLimit: { max: 10, window: 60_000 },
search: 'content'
})Org-Scoped Tables
When you need multi-tenancy, use makeOrgScoped + orgCrud:
import { makeOrgScoped } from 'noboil/convex/schema'
import { orgTables } from 'noboil/convex/server'
const orgScoped = makeOrgScoped({
wiki: object({
title: string().min(1),
content: string(),
status: zenum(['draft', 'published'])
})
})
export default defineSchema({
...orgTables(),
wiki: orgTable(orgScoped.wiki)
})export const { create, list, read, rm, update } = orgCrud(
'wiki',
orgScoped.wiki
)ESLint Plugin
Add the ESLint plugin to catch common mistakes at dev time:
import noboilConvex from 'noboil/convex/eslint'
import { defineConfig } from 'eslint/config'
export default defineConfig([noboilConvex.recommended])This catches wrong API casing (api.blogprofile vs api.blogProfile), form field typos, missing await connection() in Server Components, and more.
Type Safety with strictApi
Convex's generated api object has a runtime anyApi proxy that accepts any property name. Use strictApi to strip the index signature:
import { strictApi } from 'noboil/convex'
import { api as rawApi } from '../convex/_generated/api'
const api = strictApi(rawApi)
api.note.list
api.noet.listChecklist
| Step | Status |
|---|---|
Install noboil/convex | |
Define first Zod schema with makeOwned / makeOrgScoped | |
Add table to schema with ownedTable / orgTable | |
Call setup() in a convex file | |
Generate endpoints with crud() / orgCrud() | |
Use useList, useForm in React | |
| Add ESLint plugin | |
Wrap api with strictApi | |
| Convert remaining tables one at a time |
noboil/spacetimedb is the SpacetimeDB successor to noboil/convex. The mental model is similar, but the underlying system is different. This guide maps Convex concepts to their SpacetimeDB equivalents.
The big picture
| Concept | Convex | SpacetimeDB |
|---|---|---|
| Backend | Convex cloud | SpacetimeDB (Docker or Maincloud) |
| Data model | Document store (JSON) | Relational tables |
| Real-time | useQuery with reactive queries | useTable with WebSocket subscriptions |
| Mutations | useMutation | useReducer |
| Server functions | Convex functions (query, mutation, action) | Reducers + Procedures |
| Provider | ConvexProvider | SpacetimeDBProvider |
| Auth | ConvexAuth (JWT) | Anonymous Identity + OIDC (Maincloud) |
| File storage | Convex storage | Inline byte storage |
| IDs | Id<"tableName"> (string) | u32 (number) |
| Schema | Zod-based defineTable | t.string(), t.u32(), etc. |
Provider
import { ConvexProvider, ConvexReactClient } from 'convex/react'
const client = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!)
<ConvexProvider client={client}>
{children}
</ConvexProvider>import { SpacetimeDBProvider } from 'spacetimedb/react'
import { DbConnection } from '@/generated/module_bindings'
<SpacetimeDBProvider
uri={process.env.NEXT_PUBLIC_SPACETIMEDB_URI!}
module={process.env.NEXT_PUBLIC_MODULE_NAME!}
createConnection={() => DbConnection.builder()}
>
{children}
</SpacetimeDBProvider>Reading data
import { useQuery } from 'convex/react'
import { api } from '@/convex/_generated/api'
const posts = useQuery(api.blog.list, { published: true })import { useTable } from 'spacetimedb/react'
import { tables } from '@/generated/module_bindings'
const [posts, isReady] = useTable(tables.post)Key differences:
useQueryreturnsundefinedwhile loading.useTablereturns an empty array immediately andisReadybecomestruewhen the initial sync completes.useQuerytakes a function reference and args.useTabletakes a table reference (optionally with.where()).- Convex queries run on the server and return computed results. SpacetimeDB subscriptions return raw table rows.
Filtering
export const list = query({
args: { published: v.boolean() },
handler: async (ctx, { published }) => {
return ctx.db
.query('post')
.withIndex('by_published', q => q.eq('published', published))
.collect()
}
})
const posts = useQuery(api.blog.list, { published: true })const [posts, isReady] = useTable(tables.post.where(r => r.published.eq(true)))
import { useList } from 'noboil/spacetimedb/react'
const [allPosts, isReady] = useTable(tables.post)
const { data: posts } = useList(allPosts, isReady, {
where: { published: true }
})Pagination
import { usePaginatedQuery } from 'convex/react'
const { results, status, loadMore } = usePaginatedQuery(
api.blog.listPaginated,
{},
{ initialNumItems: 20 }
)import { useList } from 'noboil/spacetimedb/react'
const [posts, isReady] = useTable(tables.post)
const { data, hasMore, loadMore, totalCount } = useList(posts, isReady, {
pageSize: 20,
sort: { field: 'updatedAt', direction: 'desc' }
})Writing data
import { useMutation } from 'convex/react'
import { api } from '@/convex/_generated/api'
const createPost = useMutation(api.blog.create)
await createPost({ title: 'Hello', content: 'World' })import { useReducer } from 'spacetimedb/react'
import { reducers } from '@/generated/module_bindings'
const createPost = useReducer(reducers.create_post)
await createPost({ title: 'Hello', content: 'World', published: false })Key differences:
useMutationtakes a function reference from the generated API.useReducertakes a reducer reference from the generated bindings.- Both return an async function with a single object argument.
- Convex mutations can return values. SpacetimeDB reducers are fire-and-forget (use procedures for return values).
Server functions
export const getBySlug = query({
args: { slug: v.string() },
handler: async (ctx, { slug }) => {
return ctx.db
.query('post')
.withIndex('by_slug', q => q.eq('slug', slug))
.unique()
}
})
export const create = mutation({
args: { title: v.string(), content: v.string() },
handler: async (ctx, args) => {
const userId = await getAuthUserId(ctx)
return ctx.db.insert('post', { ...args, userId, published: false })
}
})
export const fetchFromApi = action({
args: { id: v.string() },
handler: async (ctx, { id }) => {
const data = await fetch(`https://api.example.com/${id}`).then(r => r.json())
await ctx.runMutation(api.cache.store, { id, data })
return data
}
})export const createPost = spacetimedb.reducer(
{ name: 'create_post' },
{ title: t.string(), content: t.string() },
(ctx, args) => {
ctx.db.post.insert({
id: 0,
title: args.title,
content: args.content,
published: false,
updatedAt: ctx.timestamp,
userId: ctx.sender
})
}
)
export const getPostBySlug = spacetimedb.procedure(
{ name: 'get_post_by_slug' },
{ slug: t.string() },
(ctx, { slug }) => {
for (const post of ctx.db.post) {
if (post.slug === slug) return post
}
throw new SenderError('NOT_FOUND: post:getBySlug')
}
)
export const GET = async (req: Request) => {
const data = await fetch('https://api.example.com/...').then(r => r.json())
return Response.json(data)
}Schema definition
import { defineSchema, defineTable } from 'convex/server'
import { v } from 'convex/values'
export default defineSchema({
post: defineTable({
title: v.string(),
content: v.string(),
published: v.boolean(),
userId: v.string()
}).index('by_published', ['published'])
})import { schema } from 'noboil/spacetimedb/schema'
import { boolean, object, string } from 'zod/v4'
const s = schema({
owned: {
post: object({
content: string(),
published: boolean(),
title: string()
})
}
})
export { s }import { noboil } from 'noboil/spacetimedb/server'
import { s } from '../s'
export default noboil({ tables: ({ table }) => ({
post: table(s.post, { pub: 'published' })
}) })Key differences:
- Convex uses
v.string(),v.boolean(), etc. SpacetimeDB uses standard Zod:string(),boolean(), etc. - Convex IDs are strings (
Id<"post">). SpacetimeDB IDs are numbers (u32). - Both add system fields automatically (
id,updatedAt,userIdin SpacetimeDB;_id,_creationTimein Convex). - SpacetimeDB uses
{ pub: 'published' }which auto-indexes and sets RLS, instead of.index('by_published', ['published']).
IDs
const postId: Id<'post'> = post._id
await updatePost({ id: postId, title: 'New title' })
const url = `/posts/${postId}`const postId: number = post.id
await updatePost({ id: postId, title: 'New title' })
const url = `/posts/${postId}`
import { idToWire } from 'noboil/spacetimedb'
const url = `/posts/${idToWire(postId)}`Auth
import { convexAuth } from '@convex-dev/auth/server'
export const { auth, signIn, signOut, store } = convexAuth({
providers: [Google]
})
const userId = await getAuthUserId(ctx)import { identityEquals } from 'noboil/spacetimedb/server'
;(ctx, args) => {
const userId = ctx.sender
}
identityEquals(ctx.sender, row.userId)
ctx.sender.toHexString()For production auth with real users, SpacetimeDB supports OIDC providers via Maincloud. Local dev uses anonymous connections with stable tokens.
File storage
const storageId = await generateUploadUrl()
await ctx.storage.getUrl(storageId)import { useUpload } from 'noboil/spacetimedb/react'
const { upload } = useUpload({
registerFile: async ({ data, ...meta }) => {
await registerUpload({ data, ...meta })
return { storageId: `${meta.filename}:${Date.now()}` }
}
})Error handling
throw new ConvexError({ code: 'NOT_FOUND', message: 'Post not found' })
try {
await createPost(data)
} catch (error) {
if (error instanceof ConvexError) {
console.log(error.data.code)
}
}throw new SenderError('NOT_FOUND: post:update')
import { extractErrorData, getErrorCode } from 'noboil/spacetimedb'
try {
await createPost(data)
} catch (error) {
const code = getErrorCode(error)
const data = extractErrorData(error)
}Real-time latency
Convex reactive queries update within ~100-300ms. SpacetimeDB subscriptions update within ~39ms (local Docker). Optimistic updates are unnecessary at this latency.
What doesn't exist in SpacetimeDB (yet)
| Convex feature | Status in SpacetimeDB |
|---|---|
usePaginatedQuery | Use useList with loadMore |
| Optimistic updates | Not needed at 39ms latency; useOptimisticMutation is a placeholder |
skip option on queries | Conditionally render the subscribing component instead |
| Built-in rate limiting | Implement manually with a tracking table |
ctx.http.fetch in reducers | Panics in local Docker; use Next.js API routes |
| Full OAuth (Google, GitHub) | Requires Maincloud; local dev uses anonymous identity |