Ejecting
Step off noboil's conventions and take full control of your backend, one endpoint at a time.
noboil is designed for incremental ejection. You can replace one factory at a time while the rest of your app keeps running. You don't have to eject everything — most apps settle somewhere in the middle.
Why eject?
- You need a query pattern that the generated CRUD can't express (complex joins, aggregations, graph traversals)
- You want to remove the dependency entirely
- You need behavior that conflicts with noboil's conventions (custom auth, non-standard ownership)
The spectrum
Full noboil/convex ←──────────────────────────→ Full raw Convex
crud() for all crud() + custom custom only, no noboil/convex
tables queries on hot keep schemas at all
pathsStep 1: Identify what to eject
Replace a crud() call only when you need behavior it doesn't support. Keep it for tables where standard CRUD is sufficient.
Signs a table needs ejecting:
- You're using
pq/q/mfor most operations on that table - The runtime filter warning (
RUNTIME_FILTER_WARN_THRESHOLD) fires consistently - You need transactions spanning multiple tables
- You need custom subscriptions or real-time patterns
Step 2: Write raw Convex equivalents
For each generated endpoint you want to replace, write the raw Convex version. Here's crud('blog', owned.blog) fully ejected:
list
import { paginationOptsValidator } from 'convex/server'
import { v } from 'convex/values'
import { query } from './_generated/server'
export const list = query({
args: { paginationOpts: paginationOptsValidator },
handler: async (ctx, { paginationOpts }) => {
return ctx.db.query('blog').order('desc').paginate(paginationOpts)
}
})create
import { v } from 'convex/values'
import { mutation } from './_generated/server'
export const create = mutation({
args: {
category: v.string(),
content: v.string(),
published: v.boolean(),
title: v.string()
},
handler: async (ctx, args) => {
const userId = await getAuthUserId(ctx)
if (!userId) throw new Error('Not authenticated')
return ctx.db.insert('blog', { ...args, userId, updatedAt: Date.now() })
}
})update
export const update = mutation({
args: {
content: v.optional(v.string()),
id: v.id('blog'),
title: v.optional(v.string())
},
handler: async (ctx, { id, ...fields }) => {
const userId = await getAuthUserId(ctx)
if (!userId) throw new Error('Not authenticated')
const doc = await ctx.db.get(id)
if (!doc || doc.userId !== userId) throw new Error('Not found')
await ctx.db.patch(id, { ...fields, updatedAt: Date.now() })
}
})rm
export const rm = mutation({
args: { id: v.id('blog') },
handler: async (ctx, { id }) => {
const userId = await getAuthUserId(ctx)
if (!userId) throw new Error('Not authenticated')
const doc = await ctx.db.get(id)
if (!doc || doc.userId !== userId) throw new Error('Not found')
await ctx.db.delete(id)
}
})Step 3: Replace one at a time
Don't replace everything at once. Replace one endpoint, verify the frontend still works, then proceed.
import { crud } from './lazy'
import { owned } from './s'
export const {
create,
rm,
update
} = crud('blog', owned.blog),
list = query({
args: { paginationOpts: paginationOptsValidator },
handler: async (ctx, { paginationOpts }) =>
ctx.db
.query('blog')
.withIndex('by_published_date')
.order('desc')
.paginate(paginationOpts)
})The frontend doesn't change — it still imports api.blog.list.
Step 4: Remove the schema wrapper (optional)
If you eject all factories for a table, you can also replace the branded schema with a plain Convex table definition:
import { ownedTable } from 'noboil/convex/server'
import { owned } from './s'
blog: ownedTable(owned.blog)import { defineTable } from 'convex/server'
import { v } from 'convex/values'
blog: defineTable({
category: v.string(),
content: v.string(),
coverImage: v.optional(
v.union(v.null(), v.object({ storageId: v.string() }))
),
published: v.boolean(),
title: v.string(),
updatedAt: v.float64(),
userId: v.string()
}).index('by_userId', ['userId'])ownedTable adds userId, updatedAt, and the by_userId index automatically. When ejecting, you must add these yourself.
Step 5: Remove frontend utilities (optional)
Replace React hooks with Convex equivalents:
| noboil/convex | Raw Convex |
|---|---|
useList(api.blog.list) | usePaginatedQuery(api.blog.list, {}, { initialNumItems: 50 }) |
useFormMutation(api.blog.create, owned.blog) | useMutation(api.blog.create) + manual form state |
useSoftDelete(api.blog.rm) | useMutation(api.blog.rm) + manual undo toast |
useOptimisticMutation(...) | useMutation(...) + optimisticUpdate option |
What you lose
| Feature | Ejected equivalent |
|---|---|
| Zod validation on every mutation | Manual v. validators or no validation |
| Automatic file cleanup on delete/update | Manual storage.delete() calls |
Conflict detection (expectedUpdatedAt) | Manual timestamp comparison |
| Rate limiting | Manual rateLimiter integration |
Author enrichment (withAuthor) | Manual user join |
| Where clause filtering | Manual .filter() or .withIndex() |
| Branded type safety | Standard TypeScript (still typed, just not branded) |
What you keep
- Your data stays exactly the same — no migration needed
- Other tables using
crud()keep working - Frontend code that imports from
api.blog.*doesn't change (same export names) - Schema definitions in
s.tscan stay as documentation even if unused
The factories (noboil() and table()) generate reducer definitions. Ejecting means writing those reducers manually instead of using the factories.
The client-side hooks (useList, usePresence, useUpload) are independent utilities. You can keep using them after ejecting from the server factories.
Step 1: Understand what the factories generate
For a setup like:
import { noboil } from 'noboil/spacetimedb/server'
import { s } from '../s'
export default noboil({ tables: ({ table }) => ({
post: table(s.post, { pub: 'published' })
}) })The factory generates these three reducers:
spacetimedb.reducer(
{ name: 'create_post' },
{ title: t.string(), content: t.string(), published: t.bool() },
(ctx, args) => {
ctx.db.post.insert({
id: 0,
title: args.title,
content: args.content,
published: args.published,
updatedAt: ctx.timestamp,
userId: ctx.sender
})
}
)
spacetimedb.reducer(
{ name: 'update_post' },
{
id: t.u32(),
title: t.string().optional(),
content: t.string().optional(),
published: t.bool().optional()
},
(ctx, args) => {
const post = ctx.db.post.id.find(args.id)
if (!post) throw new SenderError('NOT_FOUND: post:update')
if (!post.userId.isEqual(ctx.sender))
throw new SenderError('FORBIDDEN: post:update')
ctx.db.post.id.update({
...post,
...(args.title !== undefined && { title: args.title }),
...(args.content !== undefined && { content: args.content }),
...(args.published !== undefined && { published: args.published }),
updatedAt: ctx.timestamp
})
}
)
spacetimedb.reducer({ name: 'rm_post' }, { id: t.u32() }, (ctx, { id }) => {
const post = ctx.db.post.id.find(id)
if (!post) throw new SenderError('NOT_FOUND: post:rm')
if (!post.userId.isEqual(ctx.sender))
throw new SenderError('FORBIDDEN: post:rm')
ctx.db.post.id.delete(id)
})Step 2: Replace factory calls with manual reducers
Replace:
import { noboil } from 'noboil/spacetimedb/server'
import { s } from '../s'
export default noboil({ tables: ({ table }) => ({
post: table(s.post)
}) })With:
SpacetimeDB auto-increments id fields marked with .autoInc(), so passing 0 is the convention for new rows.
import { schema, t, table, SenderError } from 'spacetimedb/server'
const createPost = spacetimedb.reducer(
{ name: 'create_post' },
{ title: t.string(), content: t.string(), published: t.bool() },
(ctx, args) => {
ctx.db.post.insert({
id: 0,
title: args.title,
content: args.content,
published: args.published,
updatedAt: ctx.timestamp,
userId: ctx.sender
})
}
)
const updatePost = spacetimedb.reducer(
{ name: 'update_post' },
{
id: t.u32(),
title: t.string().optional(),
content: t.string().optional(),
published: t.bool().optional()
},
(ctx, args) => {
const post = ctx.db.post.id.find(args.id)
if (!post) throw new SenderError('NOT_FOUND: post:update')
if (!post.userId.isEqual(ctx.sender))
throw new SenderError('FORBIDDEN: post:update')
ctx.db.post.id.update({
...post,
...(args.title !== undefined && { title: args.title }),
...(args.content !== undefined && { content: args.content }),
...(args.published !== undefined && { published: args.published }),
updatedAt: ctx.timestamp
})
}
)
const rmPost = spacetimedb.reducer(
{ name: 'rm_post' },
{ id: t.u32() },
(ctx, { id }) => {
const post = ctx.db.post.id.find(id)
if (!post) throw new SenderError('NOT_FOUND: post:rm')
if (!post.userId.isEqual(ctx.sender))
throw new SenderError('FORBIDDEN: post:rm')
ctx.db.post.id.delete(id)
}
)
const reducers = spacetimedb.exportGroup({
create_post: createPost,
update_post: updatePost,
rm_post: rmPost
})Step 3: Remove noboil/spacetimedb from the module
bun remove noboil/spacetimedbUpdate imports in your module file:
import { schema, t, table, SenderError } from 'spacetimedb/server'Step 4: Keep or remove client-side utilities
The client-side hooks are independent of the server factories. You can keep using them:
import { useList, usePresence, useUpload } from 'noboil/spacetimedb/react'
import { zodFromTable } from 'noboil/spacetimedb'If you want to remove noboil/spacetimedb entirely from the client too, replace:
useListwith your own filtering/pagination logicusePresencewith directuseTable+useReducercallsuseUploadwith a custom XHR upload implementationzodFromTablewith manually written Zod schemas
Step 5: Republish and regenerate
spacetime publish my-app --module-path backend/spacetimedb/
spacetime generate --lang typescript --module-path backend/spacetimedb/ --out-dir backend/spacetimedb/module_bindings/The generated bindings are identical whether you used the factories or wrote reducers manually. The reducer names (create_post, update_post, rm_post) are what matter, not how they were defined.
What you lose by ejecting
- Automatic ownership checks (you write them manually)
- Conflict detection via
expectedUpdatedAt(you implement it) - Soft delete support (you implement it)
- Lifecycle hooks (you inline the logic)
- Future noboil improvements (you're on your own)
What you gain
- Full control over reducer logic
- No dependency on noboil's internal types
- Ability to deviate from the standard CRUD pattern