Setup
Initialize cRPC and create procedure builders.
In this guide, we'll set up cRPC in your Convex backend. You'll learn to initialize cRPC, create procedure builders, add middleware, customize context, and organize your files.
Overview
cRPC gives you a fluent, type-safe API for building Convex procedures. Instead of defining procedures with object config, you chain methods to build them. This makes it easy to add validation, middleware, and reusable patterns.
| Feature | Benefit |
|---|---|
| Fluent builder API | Chain .input(), .output(), .use() for readable code |
| Zod validation | Runtime type checking with great error messages |
| Middleware system | Reusable auth, rate limiting, logging |
| Procedure variants | publicQuery, authMutation, adminQuery patterns |
Generate Runtime
Start kitcn dev — this runs Convex and watches for changes, regenerating runtime files automatically:
npx kitcn devInitialize cRPC
<functionsDir>/generated/server.ts is the canonical server contract. It
exports initCRPC with ORM pre-wired when your default schema chains
.relations(...).
<functionsDir>/generated/auth.ts is the auth contract.
import { initCRPC } from '../functions/generated/server';
const c = initCRPC.create();Payload Transformer (Optional)
cRPC serializes inputs/outputs through a transformer at the procedure boundary.
Datesupport is always enabled (cannot be disabled)- The reserved wire tag is
$date - Pass a custom transformer only when you need extra wire-safe types
- Custom transformer is additive to the built-in Date transformer
import { createTaggedTransformer } from 'kitcn/crpc';
import { initCRPC } from '../functions/generated/server';
const transformer = createTaggedTransformer([
// Add custom codecs here (Date is already built-in)
]);
const c = initCRPC.create({ transformer });create({ transformer }) accepts either:
- A single
DataTransformer - A split
{ input, output }transformer object
Export Procedure Builders
Next, we'll export the procedure builders that you'll use throughout your codebase. These are the building blocks for all your queries, mutations, and actions.
// Public procedures - accessible from client
export const publicQuery = c.query;
export const publicMutation = c.mutation;
export const publicAction = c.action;
// Internal procedures - only callable from other backend functions
export const privateQuery = c.query.internal();
export const privateMutation = c.mutation.internal();
export const privateAction = c.action.internal();
// HTTP route builders (for REST APIs)
export const publicRoute = c.httpAction;
export const router = c.router;.internal() prevents client access — use for scheduled functions, webhooks, and server-to-server calls.
Define Your Schema
Before creating procedures, you'll need a database schema. These docs assume the ORM (see /docs/orm):
import {
convexTable,
defineSchema,
id,
index,
integer,
text,
} from 'kitcn/orm';
export const user = convexTable(
'user',
{ name: text().notNull(), email: text().notNull() },
(t) => [index('email').on(t.email)]
);
export const session = convexTable(
'session',
{
token: text().notNull(),
userId: id('user').notNull(),
expiresAt: integer().notNull(),
},
(t) => [index('token').on(t.token), index('userId').on(t.userId)]
);
export const tables = { user, session };
export default defineSchema(tables, {
strict: false,
}).relations((r) => ({
user: {
sessions: r.many.session({ from: r.user.id, to: r.session.userId }),
},
session: {
user: r.one.user({ from: r.session.userId, to: r.user.id }),
},
}));Without codegen, use initCRPC from kitcn/server with .dataModel() and optional .context().
Your First Procedure
import { query } from 'convex/server';
import { v } from 'convex/values';
export const list = query({
args: { limit: v.number() },
handler: async (ctx, args) => {
return ctx.db
.query('user')
.order('desc')
.take(args.limit);
},
});import { z } from 'zod';
import { publicQuery } from '../lib/crpc';
export const list = publicQuery
.input(z.object({ limit: z.number() }))
.query(async ({ ctx, input }) => {
return ctx.orm.query.user.findMany({ limit: input.limit });
});Procedure Types
| Type | Builder | Use For |
|---|---|---|
| Query | c.query | Read-only operations, real-time subscriptions |
| Mutation | c.mutation | Write operations, transactional updates |
| Action | c.action | Side effects, external API calls |
| HTTP Action | c.httpAction | REST endpoints, webhooks |
Adding Middleware
Define a middleware that checks auth and adds the user to context:
const authMiddleware = c.middleware(async ({ ctx, next }) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) {
throw new CRPCError({ code: 'UNAUTHORIZED' });
}
const user = await ctx.orm.query.user.findFirst({
where: { email: identity.email! },
});
if (!user) throw new CRPCError({ code: 'UNAUTHORIZED' });
return next({ ctx: { ...ctx, user } });
});Create authenticated procedure variants:
export const authQuery = c.query.use(authMiddleware);
export const authMutation = c.mutation.use(authMiddleware);ctx.user is now available in handlers:
export const me = authQuery
.output(userSchema)
.query(async ({ ctx }) => {
return ctx.user; // Typed! We know user exists
});Adding Metadata
const c = initCRPC
.meta<{
auth?: 'optional' | 'required';
role?: 'admin';
ratelimit?: string;
}>()
.create();Middleware reads metadata:
const roleMiddleware = c.middleware(({ ctx, meta, next }) => {
if (meta.role === 'admin' && !ctx.user?.isAdmin) {
throw new CRPCError({ code: 'FORBIDDEN' });
}
return next({ ctx });
});
export const adminQuery = c.query
.meta({ role: 'admin' })
.use(authMiddleware)
.use(roleMiddleware);Customizing Context
With generated initCRPC, ORM context is already wired. Chain .use() for additional context (e.g. auth middleware).
Common Procedure Variants
| Variant | Description | Middleware |
|---|---|---|
publicQuery | No auth required | None |
optionalAuthQuery | ctx.user may be null | Optional auth |
authQuery | ctx.user guaranteed | Auth required |
authMutation | Auth + rate limiting | Auth + rate limit |
adminQuery | Auth + admin role | Auth + role check |
For production bootstrap, start with CLI Registry. Use this page when you want to wire the backend by hand.
File Organization
Each file in convex/functions/ becomes a namespace:
convex/
├── functions/
│ ├── schema.ts # Database schema
│ ├── user.ts # → crpc.user.list, crpc.user.create
│ ├── session.ts # → crpc.session.getByToken
│ ├── account.ts # → crpc.account.list, crpc.account.delete
│ └── generated/
│ ├── server.ts # Generated server contract (ORM + initCRPC)
│ └── auth.ts # Generated auth contract
├── lib/
│ └── crpc.ts # Setup + procedure variants
└── shared/ # Client-importable
└── api.ts # Generated procedure metadataClient proxy mirrors this:
// File: convex/functions/user.ts, export: list
crpc.user.list.queryOptions({ limit: 10 })
// File: convex/functions/session.ts, export: getByToken
crpc.session.getByToken.queryOptions({ token: 'abc' })Migrate from Convex
If you're coming from vanilla Convex, here's what changes.
What stays the same
- Import
query,mutation,actionfromconvex/server - File-based organization in
convex/functions/ - Export functions as named exports
What's new
Key differences:
- Fluent builder API instead of object config
- Zod validation instead of
vvalidators { ctx, input }destructured params- Reusable procedure variants with built-in middleware