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.
| Builder | Auth | Context provides |
|---|---|---|
pq | Optional | viewerId (null if anon), withAuthor |
q | Required | user, viewerId, withAuthor, get (ownership-checked) |
m | Required | user, 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
| Concern | Generated crud() | Custom pq/q/m |
|---|---|---|
| Auth + ownership | Automatic | c.user, c.get(id) |
| File cleanup | Automatic | Manual (call storage.delete) |
| Where clauses | Runtime .filter() | Convex .withIndex() |
| Conflict detection | expectedUpdatedAt | c.patch(id, data, expectedUpdatedAt) |
| Author enrichment | Automatic | c.withAuthor(docs) |
| Rate limiting | rateLimit option | Manual (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 fileType 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 // generatedPattern 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.