noboil

Custom Queries

Escape hatches for custom server logic — pq/q/m builders (Convex), procedures, SQL API, and scheduled reducers (SpacetimeDB).

When generated CRUD isn't enough, drop down to the lower-level builders for custom server logic.

pq, q, m — escape hatches

setup() returns pq, q, and m — thin wrappers around Convex's query/mutation builders that inject auth context and helpers.

BuilderAuthContext provides
pqOptionalviewerId (null if anon), withAuthor
qRequireduser, viewerId, withAuthor, get (ownership-checked)
mRequireduser, get, create, patch (with conflict detection), delete

pq — Public Query (No Auth Required)

const bySlug = pq({
  args: { slug: z.string() },
  handler: async (c, { slug }) => {
    const doc = await c.db
      .query('blog')
      .withIndex('by_slug', q => q.eq('slug', slug))
      .unique()
    return doc ? (await c.withAuthor([doc]))[0] : null
  }
})

q — Authenticated Query

const listDeleted = q({
  args: { orgId: zid('org') },
  handler: async (c, { orgId }) => {
    await requireOrgMember({ db: c.db, orgId, userId: c.user._id })
    const docs = await c.db
      .query('wiki')
      .filter(f => f.eq(f.field('orgId'), orgId))
      .order('desc')
      .collect()
    const deleted: typeof docs = []
    for (const d of docs) if (d.deletedAt !== undefined) deleted.push(d)
    return deleted
  }
})

m — Authenticated Mutation

const archive = m({
  args: { id: z.string() },
  handler: async (c, { id }) => c.patch(id, { archived: true })
})

c.patch includes conflict detection — pass expectedUpdatedAt as the third argument.

Mixing custom and generated endpoints

Custom endpoints live in the same file as generated CRUD:

import { orgCrud, q, uniqueCheck } from '../lazy'
import { orgScoped } from '../s'

export const { addEditor, create, list, read, rm, update } = orgCrud(
    'wiki',
    orgScoped.wiki,
    { acl: true, softDelete: true }
  ),
  listDeleted = q({
    args: { orgId: zid('org') },
    handler: async (c, { orgId }) => {
      /* ... */
    }
  }),
  isSlugAvailable = uniqueCheck(orgScoped.wiki, 'wiki', 'slug')

Drop to raw Convex action/mutation/query when you don't need auth context:

import { action } from './_generated/server'

export const search = action({
    args: { query: v.string() },
    handler: async (_, { query }) => {
      /* call external API */
    }
  }),
  { all, get, load, refresh } = cacheCrud({
    /* ... */
  })

Outgrowing crud() — migration to custom queries

The generated where clauses use runtime .filter() after fetching documents. This works well for tables under ~1,000 documents. When a table grows past that, you'll see the RUNTIME_FILTER_WARN_THRESHOLD warning in logs.

Strict filter mode

Pass strictFilter: true to setup() to throw instead of warn:

const { crud } = setup({
  query, mutation, action, internalQuery, internalMutation,
  getAuthUserId,
  strictFilter: true,
})

Step 1: Add a Convex index

blog: ownedTable(owned.blog)
  .index('by_category', ['category'])
  .index('by_published_date', ['published', '_creationTime'])

Step 2: Write a custom query

export const listByCategory = pq({
  args: {
    category: z.string(),
    paginationOpts: z.object({
      cursor: z.string().nullable(),
      numItems: z.number()
    })
  },
  handler: async (c, { category, paginationOpts }) => {
    const results = await c.db
      .query('blog')
      .withIndex('by_category', q => q.eq('category', category))
      .order('desc')
      .paginate(paginationOpts)
    return { ...results, page: await c.withAuthor(results.page) }
  }
})

Step 3: Replace the frontend call

const results = usePaginatedQuery(
  api.blog.listByCategory,
  { category: 'tech' },
  { initialNumItems: 20 }
)

What stays, what changes

ConcernGenerated crud()Custom pq/q/m
Auth + ownershipAutomaticc.user, c.get(id)
File cleanupAutomaticManual (call storage.delete)
Where clausesRuntime .filter()Convex .withIndex()
Conflict detectionexpectedUpdatedAtc.patch(id, data, expectedUpdatedAt)
Author enrichmentAutomaticc.withAuthor(docs)
Rate limitingrateLimit optionManual (checkRateLimit from noboil/convex/server)

Decision tree

Need to read data?
  ├─ No auth required → pq
  └─ Auth required → q

Need to write data?
  └─ Always → m (gives conflict detection via c.patch)

Need to call an external API?
  └─ Use raw Convex action (not an noboil builder)

Can generated crud() handle it?
  ├─ Yes → Keep crud(). Don't write custom code.
  └─ No → Add custom alongside crud() in the same file

Type safety in custom handlers

pq context

handler: async (c, args) => {
  c.db        // full Convex DatabaseReader
  c.viewerId  // string | null (authenticated user ID, null for anonymous)
  c.withAuthor // (docs: Doc[]) => Promise<EnrichedDoc[]>
}

q context

handler: async (c, args) => {
  c.db         // full Convex DatabaseReader
  c.user       // Doc<'users'> — guaranteed non-null (throws if not authenticated)
  c.viewerId   // string — always present
  c.get        // (id: Id<T>) => Doc<T> — ownership-checked, throws if not owner
  c.withAuthor // same as pq
}

m context

handler: async (c, args) => {
  c.db     // full Convex DatabaseWriter
  c.user   // Doc<'users'>
  c.get    // ownership-checked get
  c.create // (table, data) => Id — sets userId + updatedAt automatically
  c.patch  // (id, data, expectedUpdatedAt?) => void — conflict detection built-in
  c.delete // (id) => void — ownership-checked, cleans up files
}

Coexistence patterns

Pattern 1: Custom query alongside generated CRUD

export const { create, list, read, rm, update } = crud('blog', owned.blog),
  bySlug = pq({
    args: { slug: z.string() },
    handler: async (c, { slug }) => {
      return c.db
        .query('blog')
        .withIndex('by_slug', q => q.eq('slug', slug))
        .unique()
    }
  }),
  trending = pq({
    args: {},
    handler: async c => {
      return c.db.query('blog').withIndex('by_views').order('desc').take(10)
    }
  })

All endpoints — generated and custom — export from the same file:

api.blog.list     // generated
api.blog.bySlug   // custom
api.blog.trending // custom
api.blog.create   // generated

Pattern 2: Custom mutation extending generated CRUD

export const { create, list, read, rm, update } = crud('blog', owned.blog),
  publish = m({
    args: { id: z.string() },
    handler: async (c, { id }) => {
      const doc = await c.get(id)
      await c.patch(id, { published: true })
    }
  })

Pattern 3: Gradual replacement

Replace a single generated endpoint while keeping the rest:

export const {
    create,
    read,
    rm,
    update
  } = crud('blog', owned.blog),
  list = pq({
    args: { category: z.string().optional() },
    handler: async (c, { category }) => {
      if (category)
        return c.db
          .query('blog')
          .withIndex('by_category', q => q.eq('category', category))
          .collect()
      return c.db.query('blog').order('desc').collect()
    }
  })

The frontend code doesn't change — it still imports api.blog.list.

On this page