BETTER-CONVEX

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:

ComponentMethodPurpose
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.

convex/functions/user.ts
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:

convex/lib/crpc.ts
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:

convex/functions/session.ts
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.

convex/functions/teams.ts
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 via ctx.runQuery / ctx.runMutation automatically (separate transactions).
  • caller.actions.*: explicit action dispatch in ActionCtx via ctx.runAction.
  • caller.schedule.*: schedule mutation/action procedures via ctx.scheduler.

See the Allowed Call Matrix in API Reference for the full compatibility table.

Action and Schedule Namespaces

convex/functions/jobs.ts
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.

convex/functions/organization.ts
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:

convex/functions/user.ts
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

Before (vanilla Convex)
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);
  },
});
After (cRPC)
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 v validators
  • { ctx, input } destructured params instead of (ctx, args)
  • Use z.string() for IDs at procedure boundaries (zid() only for explicit schema id() column mapping)

Next Steps

API Reference

Allowed Call Matrix

Caller contextRoot queryRoot mutationRoot actioncaller.actions.*caller.schedule.*
QueryCtx
MutationCtx
ActionCtx

Zod vs Convex Validators

ZodConvex 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')

On this page