noboil

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 noboil

Peer 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.list

Checklist

StepStatus
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

On this page