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(...).
With no output schema, a void/undefined response is serialized by Convex as
null.
Output validation is recommended when using static code generation.
What the Handler Must Return
.output() parses the value your handler returned, before it is serialized. The handler returns the schema's input type and clients receive its output type, so a schema that transforms applies on the way out.
Nothing is substituted for a missing value. A nullable schema needs an explicit null:
// Rejected - the schema is parsed against `undefined`
export const displayName = publicQuery
.input(z.object({ id: z.string() }))
.output(z.string().nullable())
.query(async ({ ctx, input }) => names[input.id]);
// Accepted
export const displayName = publicQuery
.input(z.object({ id: z.string() }))
.output(z.string().nullable())
.query(async ({ ctx, input }) => names[input.id] ?? null);Use .output(z.string().default('...')) to substitute a value instead.
TypeScript rejects most of these before they run. The case it misses is a lookup it believes is always present: under the default noUncheckedIndexedAccess: false, an index signature or array[0] types as non-undefined even when the value is absent. Enable noUncheckedIndexedAccess to close that gap. ctx.orm.query.*.findFirst returns | null, so ORM reads are unaffected.
A mismatch throws a CRPCError with code INTERNAL_SERVER_ERROR. Sanitized
structural Zod issues are available in error.data.ZodError; custom messages
and fields remain in the server-side error cause because they can contain the
rejected handler output.
Note: Model an absent value as .nullable(), not as a top-level
.optional(). Convex serializes an undefined return as null and its
returns validator cannot express top-level optionality, so
.output(z.string().optional()) publishes v.string() and the deployment
rejects the null it receives. .optional() inside an object is fine.
Note: The low-level returns: option on zCustomQuery/zCustomMutation/
zCustomAction does not share this contract — it substitutes null for an
undefined return before parsing. Prefer .output().
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 * as 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 * as 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 Convex round trip.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 * as 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') |