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: zid('user') }))
  .query(async ({ ctx, input }) => {
    return ctx.db.get(input.id);
  });
convex/functions/user.ts
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:

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

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

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

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

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

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

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 v validators
  • { ctx, input } destructured params instead of (ctx, args)
  • Use zid() for document ID validation

Next Steps

On this page