noboil

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
                 paths

Step 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/m for 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/convexRaw 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

FeatureEjected equivalent
Zod validation on every mutationManual v. validators or no validation
Automatic file cleanup on delete/updateManual storage.delete() calls
Conflict detection (expectedUpdatedAt)Manual timestamp comparison
Rate limitingManual rateLimiter integration
Author enrichment (withAuthor)Manual user join
Where clause filteringManual .filter() or .withIndex()
Branded type safetyStandard 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.ts can 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/spacetimedb

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

  • useList with your own filtering/pagination logic
  • usePresence with direct useTable + useReducer calls
  • useUpload with a custom XHR upload implementation
  • zodFromTable with 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

On this page