convex
Better Convex Core Skill (80% Path)
Use this file first for everyday feature delivery in an already configured better-convex app.
- If setup/bootstrap/env/auth wiring or project structure mirroring is missing, use
references/setup/index.md(then the relevant setup file). - If the task is advanced or niche, load only the specific feature reference listed at the end.
Scope
In scope:
- Add or update schema tables, indexes, relations, and triggers.
- Implement cRPC procedures (
query,mutation,action,httpAction) with runtime auth + rate limits. - Implement feature UI with
useCRPC()+ TanStack Query. - Add minimal high-value tests for auth, errors, and side effects.
Out of scope:
- Greenfield setup/install/env/bootstrap.
- Full plugin deep-dives (admin/organizations/polar).
- Internal package-level parity testing.
Skill Contract
- Favor
ctx.ormfor app data access. - Keep list/read paths bounded and index-aware.
- Use cRPC builders and middleware; avoid raw handler objects for new feature code.
- Use
CRPCErrorfor expected failures. - Prefer schema triggers for cross-row invariants, but move invariant maintenance to explicit mutation helpers if trigger execution is unstable (for example init/seed hangs or recursive write paths).
- Keep auth/rate-limit checks server-side.
- Inter-procedure calls:
create<Module>Handler(ctx)in queries/mutations (zero overhead) unless validation is relevant,create<Module>Caller(ctx)in actions/HTTP routes. In action context usecaller.actions.*for action procedures andcaller.schedule.*for scheduling. Import from./generated/<module>.runtime. Never callctx.runQuery/ctx.runMutation/ctx.runActiondirectly for module procedures.
Shortcut Mode (tRPC + Drizzle Mental Model)
Default assumption:
- cRPC behavior is tRPC-like (builder chain + middleware + TanStack options).
- ORM behavior is Drizzle-like (schema, relations,
findMany/findFirst,insert/update/delete).
Only remember these non-parity deltas:
- Procedure input root must be
z.object(...)(no primitive root args). - No
z.void()outputs; omit.output(...)for no-value mutations. - Stacked
.input(...)calls merge input shapes. .paginated({ limit, item })must be before.query()and auto-addsinput.cursor+input.limit, output{ page, continueCursor, isDone }.- Metadata is codegen’d onto
@convex/apileaves (api.namespace.fn.meta) so never put secrets in.meta(...); chaining.meta(...)is shallow merge and supportsdefaultMeta. - Auth metadata drives client behavior:
auth: "optional"waits for auth load then runs,auth: "required"waits then skips when logged out. ctx.ormenforces constraints + RLS;ctx.dbbypasses them.- Non-paginated
findMany()must be explicitly sized (limit, cursor mode, schemadefaultLimit, or explicitallowFullScan). - Predicate
whererequires explicit.withIndex(...); no implicit full scan fallback. - Cursor pagination uses the first
orderByfield; index that field for stable paging. maxScanapplies to cursor mode only;allowFullScanis for non-cursor full-scan opt-in.- String operators /
columnsprojection / many-relation subfilters can run post-fetch; bound result size early. - Search mode is relevance-ordered and does not support
orderBy; vector mode has stricter limits (no cursor/offset/top-level where/order). - Update/delete without
wherethrows unlessallowFullScan(). count(),aggregate(), andgroupBy()require a matchingaggregateIndex. UsegroupBy({ by, _count, _sum })instead of multiple.count()calls orfindMany+ manual JS grouping. Everybyfield must be finite-constrained (eq/in/isNull) inwhere. Seereferences/features/aggregates.md.- cRPC React queries are real-time by default (
subscribe: true); never usequeryClient.invalidateQueriesfor these subscribed paths. - In RSC,
prefetchhydrates client,calleris server-only and not hydrated,preloadQueryhydrates but can cause stale split ownership if also rendered client-side. - Better Auth Next.js shortcut is
convexBetterAuth(...); generic server-only shortcut iscreateCallerFactory(...). - Use
createAuthMutations(authClient)wrappers so logout unsubscribes auth queries before sign out. - NEVER use
ctx.runQuery/ctx.runMutation/ctx.runActiondirectly for module-to-module calls. Usecreate<Module>Handler(ctx)orcreate<Module>Caller(ctx)fromconvex/functions/generated/<module>.runtimeinstead. create<Module>Handler(ctx)— default choice for queries/mutations. Bypasses input validation, middleware, output validation → zero overhead. Query/mutation ctx only. Import from./generated/<module>.runtime.create<Module>Caller(ctx)— use in actions and HTTP routes (where handler is unavailable). Goes through validation + middleware. Root caller exposes query+mutation procedures. InActionCtx, action procedures are undercaller.actions.*; scheduling is undercaller.schedule.now|after|atwithcaller.schedule.cancel(id). Import from./generated/<module>.runtime. Each caller/handler eagerly loads every procedure in its module (no lazy loading) — split large modules to keep bundles lean.- API types (
Api,ApiInputs,ApiOutputs,Select,Insert,TableName) import from@convex/api— no manualinferApiInputs<typeof api>. - HTTP router must export as
httpRouter(notappRouter) for codegen. - Server wiring imports come from
convex/functions/generated/directory:getAuth,defineAuthfromgenerated/auth;initCRPC,QueryCtx,MutationCtx,OrmCtxfromgenerated/server;create<Module>Caller,create<Module>Handlerfromgenerated/<module>.runtime. No manualconvex/lib/orm.ts. defineAuth((ctx) => ({ ...options, triggers }))replaces splitgetAuthOptions+authTriggers. Trigger callbacks are doc-first:beforeCreate(data),onCreate(doc),onUpdate(newDoc, oldDoc)— noctxfirst param.- Internal auth functions at
internal.generated.*(notinternal.auth.*). - Async mutation batching is the default (codegen wires it). Customize per call:
execute({ batchSize, delayMs }). Opt into sync:execute({ mode: 'sync' })ordefineSchema(..., { defaults: { mutationExecutionMode: 'sync' } }). Relevant defaults:mutationBatchSize,mutationLeafBatchSize,mutationMaxRows,mutationScheduleCallCap. - Polymorphic unions are schema-first: use
actionType: discriminator({ variants, as? })inconvexTable(...). Query config does not include apolymorphicoption. Writes stay flat; reads synthesize nesteddetails(or custom alias). UsewithVariants: trueto auto-load allone()relations on discriminator tables.
Directory Boundary (Important)
This skill is directory-scoped. Do not depend on reading files outside skills/convex/**.
Use references/setup/ when the task needs:
- Project/file structure setup →
setup/index.md+setup/server.md - Auth bootstrap →
setup/auth.md - Client/provider wiring →
setup/react.md - Framework-specific setup →
setup/next.mdorsetup/start.md
For full template-level recreation: start with setup/index.md, then load relevant setup files, then load selected feature refs.
First-Pass Feature Intake (Do This Before Edits)
Lock these decisions first:
- Auth level per endpoint:
public/optionalAuth/auth/private. - Data invariants: what must always be true after writes?
- Query shape: list, detail, relation-loaded, search, or stream composition.
- Pagination mode: offset, cursor, infinite.
- Side effects: trigger vs scheduled function vs inline mutation.
- UI consumption: client hook only, RSC prefetch, or server-only caller.
- Risk paths: unauthorized, forbidden, not found, conflicts, rate limit.
Canonical File Targets
Typical feature touches:
convex/functions/schema.tsconvex/functions/<feature>.tsconvex/lib/crpc.ts(only if middleware/procedure builder changes)src/lib/convex/crpc.tsx(only if cRPC context/meta wiring changes)src/**feature UI filesconvex/functions/http.tsorconvex/routers/**for HTTP endpointsconvex/functions/crons.tsor scheduled handlers if needed
E2E Build Order (Default)
- Schema + indexes + relations.
- Trigger hooks for cross-row invariants (or explicit mutation-side sync if trigger path is unstable).
- Procedures with strict input/output + auth + rate limits.
- React hooks (query/mutation/infinite) using cRPC options.
- Optional: HTTP route(s), scheduling hooks.
- Tests for auth/error/trigger behavior.
Core Patterns
1) Schema + Relations + Trigger
// convex/functions/schema.ts
import {
convexTable,
defineRelations,
defineSchema,
defineTriggers,
id,
integer,
index,
text,
timestamp,
} from "better-convex/orm";
import { eq } from "better-convex/orm";
export const project = convexTable(
"project",
{
name: text().notNull(),
ownerId: id("user").notNull(),
openTaskCount: integer().notNull().default(0),
createdAt: timestamp().notNull().defaultNow(),
updatedAt: timestamp()
.notNull()
.defaultNow()
.$onUpdateFn(() => new Date()),
},
(t) => [index("ownerId_updatedAt").on(t.ownerId, t.updatedAt)]
);
export const task = convexTable(
"task",
{
projectId: id("project").notNull(),
title: text().notNull(),
status: text().notNull().default("open"),
createdAt: timestamp().notNull().defaultNow(),
updatedAt: timestamp()
.notNull()
.defaultNow()
.$onUpdateFn(() => new Date()),
},
(t) => [
index("projectId_updatedAt").on(t.projectId, t.updatedAt),
index("projectId_status").on(t.projectId, t.status),
]
);
const tables = { project, task };
export default defineSchema(tables, { strict: false });
export const relations = defineRelations(tables, (r) => ({
project: {
tasks: r.many.task(),
},
task: {
project: r.one.project({ from: r.task.projectId, to: r.project.id }),
},
}));
export const triggers = defineTriggers(relations, {
task: {
change: async (change, ctx) => {
const projectId = change.newDoc?.projectId ?? change.oldDoc?.projectId;
if (!projectId) return;
// Keep invariants bounded and idempotent.
const open = await ctx.orm.query.task.findMany({
where: { projectId, status: "open" },
limit: 500,
columns: { id: true },
});
await ctx.orm
.update(project)
.set({
updatedAt: new Date(),
openTaskCount: open.length,
})
.where(eq(project.id, projectId));
},
},
});
Schema rules that matter:
- Index fields that power filters/order/search.
many()relation paths need child FK indexes.- Trigger logic must be bounded and non-recursive.
- Use table defaults for consistent write behavior.
2) Procedure Builders + Middleware
// convex/lib/crpc.ts (shape reference only)
import { getHeaders } from "better-convex/auth";
import { CRPCError } from "better-convex/server";
import { getAuth } from "../functions/generated/auth";
import { initCRPC } from "../functions/generated/server";
const c = initCRPC
.meta<{
auth?: "optional" | "required";
role?: "admin";
rateLimit?: string;
}>()
.create();
function requireAuth<T>(user: T | null): T {
if (!user) {
throw new CRPCError({ code: "UNAUTHORIZED", message: "Not authenticated" });
}
return user;
}
export const publicQuery = c.query.meta({ auth: "optional" });
export const optionalAuthQuery = c.query
.meta({ auth: "optional" })
.use(async ({ ctx, next }) => {
const auth = getAuth(ctx);
const session = await auth.api.getSession({
headers: await getHeaders(ctx),
});
return next({
ctx: {
...ctx,
user: session?.user ?? null,
userId: session?.user?.id ?? null,
},
});
});
export const authQuery = c.query
.meta({ auth: "required" })
.use(async ({ ctx, next }) => {
const auth = getAuth(ctx);
const session = await auth.api.getSession({
headers: await getHeaders(ctx),
});
const user = requireAuth(session?.user ?? null);
return next({ ctx: { ...ctx, user, userId: user.id } });
});
export const publicMutation = c.mutation;
export const optionalAuthMutation = c.mutation
.meta({ auth: "optional" })
.use(async ({ ctx, next }) => {
const auth = getAuth(ctx);
const session = await auth.api.getSession({
headers: await getHeaders(ctx),
});
return next({
ctx: {
...ctx,
user: session?.user ?? null,
userId: session?.user?.id ?? null,
},
});
});
export const authMutation = c.mutation
.meta({ auth: "required" })
.use(async ({ ctx, next }) => {
const auth = getAuth(ctx);
const session = await auth.api.getSession({
headers: await getHeaders(ctx),
});
const user = requireAuth(session?.user ?? null);
return next({ ctx: { ...ctx, user, userId: user.id } });
});
export const privateAction = c.action.internal();
3) Query + Mutation Procedure Template
// convex/functions/project.ts
import { z } from "zod";
import { and, eq } from "better-convex/orm";
import { CRPCError } from "better-convex/server";
import { authMutation, authQuery } from "../lib/crpc";
import { project, task } from "./schema";
const ProjectDto = z.object({
id: z.string(),
name: z.string(),
createdAt: z.date(),
});
export const listProjects = authQuery
.input(
z.object({
cursor: z.string().nullable().default(null),
limit: z.number().min(1).max(50).default(20),
})
)
.output(
z.object({
page: z.array(ProjectDto),
continueCursor: z.string(),
isDone: z.boolean(),
})
)
.query(async ({ ctx, input }) => {
const result = await ctx.orm.query.project.findMany({
where: { ownerId: ctx.userId },
orderBy: { updatedAt: "desc" },
cursor: input.cursor,
limit: input.limit,
columns: { id: true, name: true, createdAt: true },
});
return result;
});
export const createProject = authMutation
.meta({ rateLimit: "project/create" })
.input(z.object({ name: z.string().min(1).max(120) }))
.output(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const [row] = await ctx.orm
.insert(project)
.values({ name: input.name, ownerId: ctx.userId })
.returning({ id: project.id });
return row;
});
export const toggleTask = authMutation
.meta({ rateLimit: "task/toggle" })
.input(
z.object({ projectId: z.string(), taskId: z.string(), done: z.boolean() })
)
.mutation(async ({ ctx, input }) => {
const ownedProject = await ctx.orm.query.project.findFirst({
where: { id: input.projectId, ownerId: ctx.userId },
columns: { id: true },
});
if (!ownedProject)
throw new CRPCError({ code: "NOT_FOUND", message: "Project not found" });
const current = await ctx.orm.query.task.findFirst({
where: and(
eq(task.id, input.taskId),
eq(task.projectId, ownedProject.id)
),
columns: { id: true },
});
if (!current)
throw new CRPCError({ code: "NOT_FOUND", message: "Task not found" });
await ctx.orm
.update(task)
.set({ status: input.done ? "done" : "open" })
.where(eq(task.id, current.id));
return null;
});
Procedure rules that matter:
- Always use strict
inputandoutput. - Use
.meta({ rateLimit: ... })on user-facing writes. - Throw
CRPCErrorfor expected runtime outcomes. - Keep list queries bounded (
limitand/or cursor). - Root input must be
z.object(...). - Omit
.output(...)(notz.void()) for no-value mutations. - Stack
.input(...)for reusable procedure composition when needed. - Use
.paginated(...)for infinite-query endpoints instead of hand-rolling pagination output.
3b) Inter-Procedure Composition
When one procedure calls another, use create<Module>Handler(ctx) (queries/mutations) or create<Module>Caller(ctx) (actions/HTTP). Import from ./generated/<module>.runtime. In action context use caller.actions.* and caller.schedule.* instead of ctx.runQuery/ctx.runMutation/ctx.runAction.
import { createOrganizationHandler } from "./generated/organization.runtime";
import { createSeedHandler } from "./generated/seed.runtime";
import { createAnalyticsCaller } from "./generated/analytics.runtime";
import { createReportsCaller } from "./generated/reports.runtime";
// Query/mutation → createHandler (zero overhead, bypasses validation)
export const listOrganizations = authQuery.query(async ({ ctx }) => {
const handler = createOrganizationHandler(ctx);
const orgs = await handler.listUserOrganizations();
return orgs;
});
// Mutation → createHandler for sub-procedure calls
export const seed = privateMutation.mutation(async ({ ctx }) => {
const handler = createSeedHandler(ctx);
await handler.cleanupSeedData();
await handler.seedUsers();
return null;
});
// Action → createCaller (handler unavailable)
export const generateReport = privateAction.action(async ({ ctx }) => {
const analyticsCaller = createAnalyticsCaller(ctx);
const reportsCaller = createReportsCaller(ctx);
const stats = await analyticsCaller.actions.getDailyStats({});
await reportsCaller.schedule.now.create({ type: "daily", data: stats });
return null;
});
| Context | Use | Why |
|---|---|---|
QueryCtx |
create<Module>Handler(ctx) |
Zero overhead, direct handler call |
MutationCtx |
create<Module>Handler(ctx) |
Zero overhead, same transaction |
ActionCtx |
create<Module>Caller(ctx) |
Root caller = query/mutation, caller.actions.* = action, caller.schedule.* = scheduled mutation/action |
| HTTP routes | create<Module>Caller(ctx) |
Action-like context |
4) Query Modes (Use The Right One)
Default: object where.
await ctx.orm.query.task.findMany({
where: { projectId: input.projectId, status: "open" },
orderBy: { updatedAt: "desc" },
limit: 20,
});
Callback where (Drizzle-style):
await ctx.orm.query.task.findMany({
where: (task, { and, eq }) =>
and(eq(task.projectId, input.projectId), eq(task.status, "open")),
limit: 20,
});
Predicate where for complex JS requires explicit index path first:
await ctx.orm.query.task
.withIndex("projectId_updatedAt", (q) => q.eq("projectId", input.projectId))
.findMany({
where: (_task, { predicate }) =>
predicate((row) =>
row.title.toLowerCase().includes(input.query.toLowerCase())
),
cursor: input.cursor,
limit: input.limit,
maxScan: 500,
});
Full-text search:
await ctx.orm.query.task.findMany({
search: {
index: "search_title",
query: input.query,
filters: { projectId: input.projectId },
},
cursor: input.cursor,
limit: input.limit,
});
Cursor boundary pinning + scan budget:
await ctx.orm.query.task.findMany({
where: { projectId: input.projectId },
orderBy: { updatedAt: "desc" },
cursor: input.cursor,
endCursor: input.endCursor ?? undefined,
limit: input.limit,
maxScan: 500,
});
Key-boundary paging (advanced):
const page = await ctx.orm.query.task.findMany({
pageByKey: { index: "projectId_updatedAt", order: "asc", targetMaxRows: 100 },
});
5) Mutation Patterns (Most Used)
Insert with returning:
const [created] = await ctx.orm
.insert(task)
.values({ projectId: input.projectId, title: input.title, status: "open" })
.returning({ id: task.id });
Update with explicit where:
await ctx.orm
.update(task)
.set({ title: input.title })
.where(eq(task.id, input.id));
Delete:
await ctx.orm.delete(task).where(eq(task.id, input.id));
Upsert:
await ctx.orm
.insert(project)
.values({ name: input.name, ownerId: ctx.userId })
.onConflictDoUpdate({ target: project.id, set: { name: input.name } });
Null-clearing update for optional fields:
import { unsetToken } from "better-convex/orm";
await ctx.orm
.update(task)
.set({
dueDate: input.dueDate === null ? unsetToken : input.dueDate,
})
.where(eq(task.id, input.id));
Large update/delete workloads:
await ctx.orm
.delete(task)
.where(eq(task.status, "done"))
.execute({ batchSize: 200, delayMs: 0 }); // async by default
Notes:
- Async is the default — codegen wires scheduling automatically.
- Async execution and builder
.paginate(...)are separate strategies; do not combine in one call. - Use
.execute({ mode: 'sync' })to force all rows in a single transaction.
6) Error Model
Use this map consistently:
| Code | Use |
|---|---|
BAD_REQUEST |
Invalid request shape/business precondition |
UNAUTHORIZED |
No valid session |
FORBIDDEN |
Session exists but lacks permissions |
NOT_FOUND |
Resource absent or inaccessible |
CONFLICT |
Unique/resource conflict |
TOO_MANY_REQUESTS |
Rate-limited path |
INTERNAL_SERVER_ERROR |
Unexpected failures only |
Required test pairing:
| Error code | Required test |
|---|---|
UNAUTHORIZED |
unauthenticated query/mutation is rejected |
FORBIDDEN |
authenticated but unauthorized actor is rejected |
NOT_FOUND |
missing/inaccessible resource path returns expected error |
CONFLICT |
duplicate/conflicting write path is rejected |
TOO_MANY_REQUESTS |
write path enforces rate limit |
7) React Query Integration
Preconditions (must be true before writing/using useCRPC() code paths):
- Generated imports exist (
@convex/api) from setup bootstrap. - Provider chain is mounted (
CRPCProviderinside QueryClient + Convex provider flow). - If bootstrap/provider prerequisites are missing, stop feature work and complete
references/setup/first. - Backend state is project-local in
.convex/(per worktree/clone), so debug state in the current repo instead of~/.convex.
useCRPC() pattern:
const crpc = useCRPC();
const projects = useQuery(
crpc.project.listProjects.queryOptions({ cursor: null, limit: 20 })
);
const createProject = useMutation(crpc.project.createProject.mutationOptions());
// No invalidateQueries: subscribed queries update via Convex real-time.
If generated API import is missing (@convex/api):
- Stop feature coding and finish bootstrap first.
- Follow
references/setup/server.mdSection 5.5 for exact setup/bootstrap commands and recovery steps. - Continue only after generated artifacts exist.
Key client defaults/deltas:
- Queries are real-time by default (
subscribe: true). - Never use
queryClient.invalidateQueriesfor subscribed cRPC query paths. - Use
{ subscribe: false }only for one-time fetches; refresh those with explicitrefetch/fetchQuery. - Use
skipUnauth: trueto avoid unauthorized fetch churn. - For pagination, use
useInfiniteQueryfrombetter-convex/react. - Prefer typed
queryKey(...)helpers for cache read/write/fetch ops instead of manual keys. - For auth flows, prefer
createAuthMutations(...)wrappers (not raw auth client calls) to avoid logout race errors.
Infinite query pattern:
import { useInfiniteQuery } from "better-convex/react";
const feed = useInfiniteQuery(
crpc.task.list.infiniteQueryOptions({ projectId }, { skipUnauth: true })
);
8) RSC Patterns (Next.js)
Use exactly one of these per use case:
prefetch(...)(preferred): non-blocking, hydrated, client owns data.caller.*: blocking server-only logic (redirects/auth checks), not hydrated.preloadQuery(...): blocking + hydrated when server needs data immediately.
Do not render preloadQuery result on server and again on client for the same data path.
Hydration rule:
HydrateClientmust wrap all client components that consume prefetched queries.
9) HTTP Route Pattern (When Feature Needs REST/Webhooks)
// router endpoint example
import { createTaskCaller } from "../functions/generated/task.runtime";
export const createTaskRoute = authRoute
.post("/api/projects/:projectId/tasks")
.params(z.object({ projectId: z.string() }))
.input(z.object({ title: z.string().min(1) }))
.output(z.object({ id: z.string() }))
.mutation(async ({ ctx, params, input }) => {
const caller = createTaskCaller(ctx);
const id = await caller.createFromHttp({
projectId: params.projectId,
title: input.title,
userId: ctx.userId,
});
return { id };
});
HTTP-specific rules:
- Use
z.coerce.*for search params. - Keep auth and permission checks in middleware/procedure.
- Apply rate limits to public/heavy endpoints.
- Validate webhook signatures before any side effects.
- Use
publicRoute/authRoute/optionalAuthRoutebuilders fromconvex/lib/crpc.ts. - Compose endpoints with
router(...)for feature-level HTTP grouping. - Client calls must pass path/query args as
{ params, searchParams }; query values are strings.
10) Scheduling Pattern (If Needed)
Immediate side effect after commit:
const caller = createTaskCaller(ctx);
await caller.schedule.now.sendTaskCreated({
taskId: created.id,
userId: ctx.userId,
});
Future execution:
const caller = createTaskCaller(ctx);
await caller.schedule.at(input.sendAt).sendReminder({
taskId: input.taskId,
userId: ctx.userId,
});
Scheduling rules:
- Auth context is not propagated; pass user/org IDs explicitly.
- Mutation scheduling is atomic with the mutation transaction.
- Store returned job IDs when cancellation is required.
- Scheduling inside actions is not atomic with action failure.
- Cron schedules run in UTC.
- Use
ctx.scheduler.*directly only when you must schedule non-procedureinternal.*functions.
11) Testing Baseline (High Signal)
Minimum feature test set:
- happy path query/mutation
- unauthenticated rejection (
UNAUTHORIZED) - permission/ownership rejection (
FORBIDDENwhere relevant) - missing resource (
NOT_FOUND) - trigger side effect assertion
- scheduler assertion if feature schedules work
- not-found checks should use real IDs or non-ID lookup keys (slug/name/email), not synthetic IDs
Low-friction fallback (when deployment bootstrap blocks full integration tests):
- Extract pure helpers for guard logic (
assertSessionUser, ownership checks, trigger counters). - Unit-test those helpers directly with Bun/Vitest.
- Keep one smoke integration test once Convex bootstrap is available.
Minimal harness shape:
test("feature scenario", async () => {
const t = convexTest(schema);
await t.run(async (baseCtx) => {
const ctx = await runCtx(baseCtx);
// arrange / act / assert
});
});
Performance + Safety Checklist
Before calling a feature done:
- Every list query is bounded (
limit/cursor). - Filters/order align with indexes.
- Expensive post-fetch logic uses pre-narrowed index path.
- Mutations use targeted
whereand avoid accidental full scans. - Trigger logic is bounded, idempotent, and avoids ping-pong loops.
- Error codes are explicit and intentional.
- User-facing writes have rate-limit metadata.
- Tests cover auth + not-found + side effects.
ctx.dbis not used on paths that rely on ORM constraints/RLS.- Paginated endpoints use
.paginated(...)+ ORM cursor flow (not ad-hoc wrappers). - For any predicate/full-scan-like path,
.withIndex(...)+ bound (limit/maxScan) is explicit. - NEVER use
@ts-nocheck, no global lint-rule downgrades, no unresolved lint warnings in touched files.
Common Mistakes (And Fixes)
| Mistake | Correct pattern |
|---|---|
| Raw Convex handler for new feature procedures | cRPC builders (publicQuery, authMutation, etc.) |
| Write-time side effects duplicated across mutations | Schema trigger, or one centralized mutation-side sync helper when trigger path is unsafe |
| Missing bounds on list/search | Add limit + cursor/pagination |
orderBy written as array objects |
Use object form: orderBy: { updatedAt: "desc" } |
Using ctx.db for policy-sensitive reads |
Use ctx.orm (RLS/constraints path) |
Throwing generic Error for expected outcomes |
Throw CRPCError with explicit code |
| Infinite list with TanStack native hook directly | Use useInfiniteQuery from better-convex/react |
Primitive root input (z.string()) |
Use root z.object(...) input schema |
Returning nothing with z.void() |
Omit explicit output |
| Manual pagination wrappers for infinite endpoints | Use .paginated({ limit, item }) |
Synthetic Convex IDs in tests ("missing-id") |
Use inserted IDs or semantic lookup keys |
| Aggregates disabled but helper/config still present | Remove aggregate helper + defineTriggers handlers + app config together |
Putting secrets in .meta(...) |
Keep metadata non-sensitive (client-visible) |
Using ctx.runQuery/ctx.runMutation/ctx.runAction directly |
Use create<Module>Handler(ctx) in queries/mutations, create<Module>Caller(ctx) in actions/HTTP with caller.actions.* / caller.schedule.* (from generated/<module>.runtime) |
Using createCaller in query/mutation context |
Use create<Module>Handler(ctx) — zero overhead, bypasses redundant validation |
Adding // @ts-nocheck to unblock compile |
NEVER do this; fix the underlying types using canonical patterns in references/setup/ |
| Relaxing lint rules to pass checks | Keep baseline lint config; fix code-level warnings/errors instead |
Reference Escalation Map (Load Only If Needed)
Setup (once per project):
references/setup/index.md: bootstrap, env, decision intake, gates, checklist, troubleshootingreferences/setup/server.md: core backend (schema, ORM, cRPC) + optional module gatesreferences/setup/auth.md: auth core bootstrap + plugin setupreferences/setup/react.md: client core (QueryClient, provider, cRPC context)references/setup/next.md: Next.js App Router setupreferences/setup/start.md: TanStack Start setupreferences/setup/doc-guidelines.md: skill/docs sync contract
Features (per session, self-contained):
references/features/orm.md: full ORM API, constraints, RLS, advanced mutations, filtering/search/composition/paginationreferences/features/react.md: full client, RSC, hydration, error handling matrixreferences/features/http.md: typed REST routes, webhooks, streamingreferences/features/scheduling.md: cron + delayed job patternsreferences/features/testing.md: deeper testing scenariosreferences/features/aggregates.md: aggregate component patternsreferences/features/migrations.md: built-in online data migrations (defineMigration, CLI, deploy, drift). Load when: task involves data backfills, optional→required field hardening, field renames/removals, type narrowing, orbetter-convex migrateCLI commands. Skip for backward-compatible changes (new optional fields, new tables, code-level defaults).references/features/auth.md: full Better Auth core flowreferences/features/auth-admin.md: admin plugin detailsreferences/features/auth-organizations.md: org/multi-tenant plugin details