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 |
Initialize cRPC
Let's start by creating the cRPC instance. You only need to do this once per backend.
Create a file at convex/lib/crpc.ts. This is where all your procedure builders will live.
import { initCRPC } from 'better-convex/server';
import {
query,
mutation,
action,
httpAction,
internalQuery,
internalMutation,
internalAction,
} from '../functions/_generated/server';
import type { DataModel } from '../functions/_generated/dataModel';
const c = initCRPC
.dataModel<DataModel>()
.create({
query,
mutation,
action,
httpAction,
internalQuery,
internalMutation,
internalAction,
});These import paths assume convex.json has "functions": "convex/functions". If using a different folder structure, adjust paths accordingly.
The initCRPC builder chains configuration methods:
.dataModel<DataModel>()- Enables type-safe table access viactx.db.create({ ... })- Passes Convex's generated functions to create typed builders
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;Note: Internal procedures use .internal() which prevents them from being called directly from the client. Use these for scheduled functions, webhooks, and server-to-server calls.
That's it for the basic setup! You can now use publicQuery, publicMutation, etc. to define your procedures.
Define Your Schema
Before creating procedures, you'll need a database schema. cRPC works with both vanilla Convex schemas and Convex Ents.
With vanilla Convex, you define tables using defineSchema and defineTable:
import { defineSchema, defineTable } from 'convex/server';
import { v } from 'convex/values';
export default defineSchema({
user: defineTable({
name: v.string(),
email: v.string(),
}).index('email', ['email']),
session: defineTable({
token: v.string(),
userId: v.id('user'),
expiresAt: v.number(),
})
.index('token', ['token'])
.index('userId', ['userId']),
});With Convex Ents, you get relationships, field defaults, and cascading deletes:
import { v } from 'convex/values';
import { defineEnt, defineEntSchema } from 'convex-ents';
const schema = defineEntSchema({
user: defineEnt({
name: v.string(),
email: v.string(),
})
.index('email', ['email'])
.edges('sessions', { to: 'session', ref: 'userId' }),
session: defineEnt({
token: v.string(),
expiresAt: v.number(),
})
.index('token', ['token'])
.edge('user', { to: 'user', field: 'userId' }),
});
export default schema;See Convex Ents for the full guide on relationships and advanced features.
Your First Procedure
Now let's use our builders to create a simple query. Here's the before and after:
Before (vanilla Convex):
import { query } from './_generated/server';
import { v } from 'convex/values';
export const list = query({
args: { limit: v.number() },
handler: async (ctx, args) => {
return ctx.db.query('user').take(args.limit);
},
});After (cRPC):
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.db.query('user').take(input.limit);
});The key differences:
- Fluent API - Chain
.input()and.query()instead of object config - Zod validation - Use
z.object()instead ofvvalidators for richer validation - Destructured params - Access
{ ctx, input }instead of separate(ctx, args)
Procedure Types
cRPC provides builders for each Convex function type:
| 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
Middleware lets you add reusable logic to your procedures. Let's add authentication.
First, 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.db
.query('user')
.withIndex('email', (q) => q.eq('email', identity.email!))
.unique();
return next({ ctx: { ...ctx, user } });
});Now create an authenticated procedure variant:
export const authQuery = c.query.use(authMiddleware);
export const authMutation = c.mutation.use(authMiddleware);When you use authQuery, the middleware runs first. If auth fails, it throws. If it succeeds, ctx.user is available in your handler:
export const me = authQuery
.output(userSchema)
.query(async ({ ctx }) => {
return ctx.user; // Typed! We know user exists
});Adding Metadata
Sometimes you need procedure-level configuration that middleware can read. Define a meta type:
type Meta = {
auth?: 'optional' | 'required';
role?: 'admin';
rateLimit?: string;
};
const c = initCRPC
.dataModel<DataModel>()
.meta<Meta>()
.create({ ... });Now middleware can check 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
You can add custom properties to the base context. This is useful for adding helpers like ctx.table from Convex Ents:
const c = initCRPC
.dataModel<DataModel>()
.context({
query: (ctx) => ({ ...ctx, table: getTable(ctx) }),
mutation: (ctx) => ({ ...ctx, table: getTable(ctx) }),
})
.create({ ... });Now all your procedures have ctx.table available automatically.
Common Procedure Variants
Here's a summary of the procedure variants you'll typically create:
| 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 |
See Templates for complete implementations with rate limiting, error handling, and more.
File Organization
cRPC uses file-based organization. Each file in convex/functions/ becomes a namespace:
convex/
├── functions/
│ ├── user.ts # → crpc.user.list, crpc.user.create
│ ├── session.ts # → crpc.session.getByToken
│ └── account.ts # → crpc.account.list, crpc.account.delete
├── lib/
│ └── crpc.ts # Setup + procedure variants
└── schema.ts # Database schemaOn the client, the proxy mirrors this structure:
// 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,actionfrom_generated/server - File-based organization in
convex/functions/ - Export functions as named exports
What's new
Before (vanilla Convex):
import { query } from './_generated/server';
import { v } from 'convex/values';
export const list = query({
args: { limit: v.number() },
handler: async (ctx, args) => {
return ctx.db.query('user').take(args.limit);
},
});After (cRPC):
import { publicQuery } from '../lib/crpc';
import { z } from 'zod';
export const list = publicQuery
.input(z.object({ limit: z.number() }))
.query(async ({ ctx, input }) => {
return ctx.db.query('user').take(input.limit);
});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
Full Template
For the complete crpc.ts setup file with auth middleware, rate limiting, and procedure variants: