kitcn

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.

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

convex/functions/user.ts
// 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 mutations
  • create<Module>Caller(ctx) is for actions and HTTP routes

Runtime modules export module-named factories: create<Module>Caller and create<Module>Handler.

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

caller.actions.* is action-only. If a callback can run from mutation or action context, keep the seam honest and schedule the action:

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

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