Procedures
Define inputs, outputs, and handlers with the fluent API.
Input Validation
Use .input() to define and validate procedure arguments. The schema runs before your handler, catching invalid data early.
export const getById = publicQuery
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
return ctx.orm.query.user.findFirst({ where: { id: input.id } });
});Schema Format
Pass a z.object() schema directly. You get all of Zod's validation power - string lengths, email formats, optional fields, and more:
.input(z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
emailVerified: z.boolean().optional(),
}))Note: Convex requires z.object() at the root level. You can't use primitive types like z.string() directly.
No Input
For procedures that take no arguments, simply omit .input():
export const list = publicQuery
.query(async ({ ctx }) => {
return ctx.orm.query.user.findMany({ limit: 100 });
});Input Merging
You can stack .input() calls to build complex types. This is especially useful when middleware needs to validate its own input:
First, define a procedure that validates and fetches a target user:
const userProcedure = authQuery
.input(z.object({ userId: z.string() }))
.use(async ({ ctx, input, next }) => {
const targetUser = await ctx.orm.query.user.findFirst({
where: { id: input.userId },
});
if (!targetUser) throw new CRPCError({ code: 'NOT_FOUND' });
return next({ ctx: { ...ctx, targetUser } });
});Now when you extend this procedure, inputs are merged automatically:
export const list = userProcedure
.input(z.object({ limit: z.number().default(10) }))
.query(async ({ ctx, input }) => {
// input.userId + input.limit both available!
return ctx.orm.query.session.findMany({
where: { userId: input.userId },
limit: input.limit,
});
});Output Validation
Use .output() to validate return values. This catches bugs where your handler returns unexpected data.
export const getById = publicQuery
.input(z.object({ id: z.string() }))
.output(
z.object({
id: z.string(),
name: z.string(),
email: z.string(),
})
)
.query(async ({ ctx, input }) => {
const user = await ctx.orm.query.user.findFirst({ where: { id: input.id } });
if (!user) throw new CRPCError({ code: 'NOT_FOUND' });
return user;
});Note: For mutations/actions that do not return data, omit .output(...).
void/undefined responses are serialized by Convex as null.
Output validation is recommended when using static code generation.
Handler Methods
Queries
Use .query() for read-only operations. Queries are cached and support real-time subscriptions - when data changes, clients update automatically.
export const list = publicQuery
.input(z.object({ limit: z.number().default(10) }))
.query(async ({ ctx, input }) => {
return ctx.orm.query.user.findMany({ limit: input.limit });
});Mutations
Use .mutation() for write operations. Mutations are transactional - if any part fails, the entire operation rolls back.
import { z } from 'zod';
import { eq } from 'kitcn/orm';
import { user } from './schema';
export const create = publicMutation
.input(z.object({ name: z.string(), email: z.string().email() }))
.mutation(async ({ ctx, input }) => {
await ctx.orm.insert(user).values(input);
});
export const remove = publicMutation
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
await ctx.orm.delete(user).where(eq(user.id, input.id));
});Actions
Use .action() for side effects and external API calls. Actions can call queries and mutations via callers.
export const sendWelcomeEmail = publicAction
.input(z.object({ to: z.string().email(), name: z.string() }))
.action(async ({ ctx, input }) => {
await sendEmail({ to: input.to, subject: `Welcome, ${input.name}!` });
return { sent: true };
});In-Process Procedure Composition
When one procedure needs to call another, use the module runtime factory from
convex/functions/generated/<module>.runtime.ts.
create<Module>Handler(ctx)is the default in queries and mutationscreate<Module>Caller(ctx)is for actions and HTTP routes
Runtime modules export module-named factories:
create<Module>Caller and create<Module>Handler.
import { z } from 'zod';
import { privateQuery } from '../lib/crpc';
import { createTeamsHandler } from './generated/teams.runtime';
export const getProjectAndOwner = privateQuery
.input(z.object({ projectId: z.string() }))
.query(async ({ ctx, input }) => {
const handler = createTeamsHandler(ctx);
const project = await handler.getProject({ projectId: input.projectId });
const owner = await handler.getOwner({ id: project.ownerId });
return { project, owner };
});How It Works
create<Module>Caller(ctx) detects your context type and dispatches accordingly:
QueryCtx/MutationCtx: invokes the procedure handler directly — same transaction, no extra overhead.ActionCtx: root calls dispatch viactx.runQuery/ctx.runMutationautomatically (separate transactions).caller.actions.*: explicit action dispatch inActionCtxviactx.runAction.caller.schedule.*: schedule mutation/action procedures viactx.scheduler.
See the Allowed Call Matrix in API Reference for the full compatibility table.
Action and Schedule Namespaces
const caller = createJobsCaller(ctx);
// Direct action call (ActionCtx only)
await caller.actions.reindex({ force: true });
// Schedule mutation/action calls (MutationCtx + ActionCtx)
await caller.schedule.now.enqueueReport({ reportId: 'r_1' });
await caller.schedule.after(5000).reindex({ force: true });
await caller.schedule.at(Date.now() + 60_000).reindex({ force: true });caller.actions.* is action-only. If a callback can run from mutation or
action context, keep the seam honest and schedule the action:
import { requireSchedulerCtx } from 'kitcn/server';
const caller = createJobsCaller(requireSchedulerCtx(ctx));
await caller.schedule.now.reindex({ force: true });Note: In actions, each create<Module>Caller(ctx) call runs as a separate Convex transaction. Prefer aggregating related reads/writes into a single internal query/mutation when consistency matters.
Bundle size: Each caller eagerly loads every procedure in its module — there is no lazy loading. If a module grows large, split it into smaller files so callers only pull in what they need.
create<Module>Handler(ctx)
For internal composition where the caller already validated inputs, use create<Module>Handler(ctx). It bypasses input validation, middleware, and output validation — calling the raw handler directly.
import { z } from 'zod';
import { authQuery } from '../lib/crpc';
import { createOrganizationHandler } from './generated/organization.runtime';
export const listOrganizations = authQuery
.query(async ({ ctx }) => {
const handler = createOrganizationHandler(ctx);
const orgs = await handler.listUserOrganizations();
return orgs;
});create<Module>Handler(ctx) is query/mutation-only (no action context support).
Paginated Queries
For large datasets, use .paginated() for cursor-based pagination. It automatically adds cursor and limit to your input, and wraps output with pagination metadata.
const SessionSchema = z.object({
id: z.string(),
userId: z.string(),
token: z.string(),
});
export const list = publicQuery
.input(z.object({ userId: z.string().optional() }))
.paginated({ limit: 20, item: SessionSchema })
.query(async ({ ctx, input }) => {
return ctx.orm.query.session.findMany({
where: input.userId ? { userId: input.userId } : {},
orderBy: { createdAt: 'desc' },
cursor: input.cursor,
limit: input.limit,
});
});The handler receives flat input.cursor and input.limit. Pass them to findMany({ cursor, limit }). The output is automatically typed as { continueCursor: string, isDone: boolean, page: T[] }.
See Infinite Queries for client-side usage with useInfiniteQuery.
Internal Procedures
Use privateMutation, privateQuery, or privateAction for procedures only callable from other Convex functions. These are perfect for scheduled jobs, background processing, and server-to-server calls.
export const processJob = privateMutation
.input(z.object({ data: z.string() }))
.mutation(async ({ ctx, input }) => {
// Only callable via callers or ctx.scheduler
});
export const backfillData = privateMutation
.input(z.object({ cursor: z.string().nullable() }))
.mutation(async ({ ctx, input }) => {
// Background job for data migration
});Note: These builders use .internal() under the hood. You can also call .internal() on any builder if needed.
Next Steps
API Reference
Allowed Call Matrix
| Caller context | Root query | Root mutation | Root action | caller.actions.* | caller.schedule.* |
|---|---|---|---|---|---|
QueryCtx | ✅ | ❌ | ❌ | ❌ | ❌ |
MutationCtx | ✅ | ✅ | ❌ | ❌ | ✅ |
ActionCtx | ✅ | ✅ | ❌ | ✅ | ✅ |
Zod vs Convex Validators
| Zod | Convex v |
|---|---|
z.string() | v.string() |
z.number() | v.number() |
z.boolean() | v.boolean() |
z.array(z.string()) | v.array(v.string()) |
z.object({...}) | v.object({...}) |
z.string().optional() | v.optional(v.string()) |
zid('tablename') | v.id('tablename') |