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 |