Procedures
Define inputs, outputs, and handlers with the fluent API.
In this guide, we'll learn how to define cRPC procedures. You'll master input validation with Zod, output schemas, handler methods, and pagination - the building blocks for all your backend logic.
Overview
Procedures are the core building blocks of cRPC. Each procedure chains together:
| Component | Method | Purpose |
|---|---|---|
| Input | .input() | Validate arguments with Zod |
| Output | .output() | Validate return values (optional) |
| Handler | .query() / .mutation() / .action() | Execute server logic |
Let's explore each component.
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
Now let's look at the three handler types. Each serves a different purpose.
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 (for example createTeamsCaller(ctx)). This gives you a type-safe proxy with full autocomplete — no more manually choosing between ctx.runQuery, ctx.runMutation, ctx.runAction, and ctx.scheduler.
Runtime modules export module-named factories:
create<Module>Caller and create<Module>Handler.
import { z } from 'zod';
import { privateQuery } from '../lib/crpc';
import { createTeamsCaller } from './generated/teams.runtime';
export const getProjectAndOwner = privateQuery
.input(z.object({ projectId: z.string() }))
.query(async ({ ctx, input }) => {
const caller = createTeamsCaller(ctx);
const project = await caller.getProject({ projectId: input.projectId });
const owner = await caller.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 });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.
Complete Example
Here's a full CRUD example showing all the patterns together:
import { z } from 'zod';
import { eq } from 'kitcn/orm';
import { publicQuery, publicMutation } from '../lib/crpc';
import { user } from './schema';
// Define reusable schema
const userSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
});
export const list = publicQuery.query(async ({ ctx }) => {
return ctx.orm.query.user.findMany({ limit: 100 });
});
export const getById = publicQuery
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
return ctx.orm.query.user.findFirst({ where: { id: input.id } });
});
export const create = publicMutation
.input(userSchema)
.output(z.string())
.mutation(async ({ ctx, input }) => {
const [row] = await ctx.orm
.insert(user)
.values(input)
.returning({ id: user.id });
return row.id;
});
export const update = publicMutation
.input(userSchema.partial().extend({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const { id, ...data } = input;
await ctx.orm.update(user).set(data).where(eq(user.id, id));
});Migrate from Convex
cRPC uses Zod for validation instead of Convex's v validators. See Zod vs Convex Validators in API Reference for the full mapping table.
If you're coming from vanilla Convex, here's what changes.
What stays the same
- Export functions as named exports
- Queries for reads, mutations for writes, actions for side effects
What's new
import { query } from 'convex/server';
import { v } from 'convex/values';
export const getById = query({
args: { id: v.id('user') },
handler: async (ctx, args) => {
return ctx.db.get(args.id);
},
});import { z } from 'zod';
import { publicQuery } from '../lib/crpc';
export const getById = publicQuery
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
return ctx.orm.query.user.findFirst({ where: { id: input.id } });
});Key differences:
- Fluent builder API instead of object config
- Zod validation instead of
vvalidators { ctx, input }destructured params instead of(ctx, args)- Use
z.string()for IDs at procedure boundaries (zid()only for explicit schemaid()column mapping)
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') |