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: zid('user') }))
.query(async ({ ctx, input }) => {
return ctx.db.get(input.id);
});export const getById = publicQuery
.input(z.object({ id: zid('user') }))
.query(async ({ ctx, input }) => {
return ctx.table('user').get(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.db.query('user').collect();
});export const list = publicQuery
.query(async ({ ctx }) => {
return ctx.table('user');
});Input Merging
Here's where cRPC gets powerful. 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: zid('user') }))
.use(async ({ ctx, input, next }) => {
const targetUser = await ctx.db.get(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.db.query('session')
.withIndex('userId', q => q.eq('userId', input.userId))
.take(input.limit);
});First, define a procedure that validates and fetches a target user:
const userProcedure = authQuery
.input(z.object({ userId: zid('user') }))
.use(async ({ ctx, input, next }) => {
const targetUser = await ctx.table('user').get(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.table('session', 'userId', q => q.eq('userId', input.userId))
.take(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: zid('user') }))
.output(z.object({
_id: zid('user'),
name: z.string(),
email: z.string(),
}))
.query(async ({ ctx, input }) => {
const user = await ctx.db.get(input.id);
if (!user) throw new CRPCError({ code: 'NOT_FOUND' });
return user;
});export const getById = publicQuery
.input(z.object({ id: zid('user') }))
.output(z.object({
_id: zid('user'),
name: z.string(),
email: z.string(),
}))
.query(async ({ ctx, input }) => {
const user = await ctx.table('user').getX(input.id);
return user;
});Note: z.void() is not supported by Convex. For mutations that don't return a value, use .output(z.null()). Convex returns null by default, so an explicit return null is not required.
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.db.query('user').take(input.limit);
});export const list = publicQuery
.input(z.object({ limit: z.number().default(10) }))
.query(async ({ ctx, input }) => {
return ctx.table('user').take(input.limit);
});Mutations
Use .mutation() for write operations. Mutations are transactional - if any part fails, the entire operation rolls back.
export const create = publicMutation
.input(z.object({ name: z.string(), email: z.string().email() }))
.mutation(async ({ ctx, input }) => {
return ctx.db.insert('user', input);
});
export const remove = publicMutation
.input(z.object({ id: zid('user') }))
.mutation(async ({ ctx, input }) => {
await ctx.db.delete(input.id);
});export const create = publicMutation
.input(z.object({ name: z.string(), email: z.string().email() }))
.mutation(async ({ ctx, input }) => {
return ctx.table('user').insert(input);
});
export const remove = publicMutation
.input(z.object({ id: zid('user') }))
.mutation(async ({ ctx, input }) => {
await ctx.table('user').getX(input.id).delete();
});Actions
Use .action() for side effects and external API calls. Actions can call queries and mutations via ctx.runQuery and ctx.runMutation.
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 };
});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: zid('session'),
userId: zid('user'),
token: z.string(),
});
export const list = publicQuery
.input(z.object({ userId: zid('user').optional() }))
.paginated({ limit: 20, item: SessionSchema })
.query(async ({ ctx, input }) => {
return ctx.db
.query('session')
.withIndex('userId', (q) =>
input.userId ? q.eq('userId', input.userId) : q
)
.order('desc')
.paginate({ cursor: input.cursor, numItems: input.limit });
});const SessionSchema = z.object({
_id: zid('session'),
userId: zid('user'),
token: z.string(),
});
export const list = publicQuery
.input(z.object({ userId: zid('user').optional() }))
.paginated({ limit: 20, item: SessionSchema })
.query(async ({ ctx, input }) => {
return ctx.table('session', 'userId', (q) =>
input.userId ? q.eq('userId', input.userId) : q
)
.order('desc')
.paginate({ cursor: input.cursor, numItems: input.limit });
});The handler receives flat input.cursor and input.limit. Transform them for Convex's .paginate({ cursor, numItems }). 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 ctx.runMutation(internal.jobs.processJob, {...})
});
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 { zid } from 'convex-helpers/server/zod4';
import { publicQuery, publicMutation } from '../lib/crpc';
// 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.db.query('user').collect();
});
export const getById = publicQuery
.input(z.object({ id: zid('user') }))
.query(async ({ ctx, input }) => {
return ctx.db.get(input.id);
});
// Use the schema directly
export const create = publicMutation
.input(userSchema)
.mutation(async ({ ctx, input }) => {
return ctx.db.insert('user', input);
});
// Extend schemas with .extend()
export const update = publicMutation
.input(userSchema.partial().extend({ id: zid('user') }))
.mutation(async ({ ctx, input }) => {
const { id, ...data } = input;
return ctx.db.patch(id, data);
});import { z } from 'zod';
import { zid } from 'convex-helpers/server/zod4';
import { publicQuery, publicMutation } from '../lib/crpc';
// 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.table('user');
});
export const getById = publicQuery
.input(z.object({ id: zid('user') }))
.query(async ({ ctx, input }) => {
return ctx.table('user').get(input.id);
});
// Use the schema directly
export const create = publicMutation
.input(userSchema)
.mutation(async ({ ctx, input }) => {
return ctx.table('user').insert(input);
});
// Extend schemas with .extend()
export const update = publicMutation
.input(userSchema.partial().extend({ id: zid('user') }))
.mutation(async ({ ctx, input }) => {
const { id, ...data } = input;
return ctx.table('user').getX(id).patch(data);
});Zod vs Convex Validators
cRPC uses Zod for validation instead of Convex's v validators. Here's a quick reference:
| 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') |
Note: zid is imported from convex-helpers/server/zod4 and validates Convex document IDs with full type safety.
Migrate from Convex
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
Before (vanilla Convex):
import { query } from './_generated/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);
},
});After (cRPC):
import { z } from 'zod';
import { zid } from 'convex-helpers/server/zod4';
import { publicQuery } from '../lib/crpc';
export const getById = publicQuery
.input(z.object({ id: zid('user') }))
.query(async ({ ctx, input }) => {
return ctx.db.get(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
zid()for document ID validation