BETTER-CONVEX

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.

FeatureBenefit
Fluent builder APIChain .input(), .output(), .use() for readable code
Zod validationRuntime type checking with great error messages
Middleware systemReusable auth, rate limiting, logging
Procedure variantspublicQuery, authMutation, adminQuery patterns

Generate Runtime

Start kitcn dev — this runs Convex and watches for changes, regenerating runtime files automatically:

npx kitcn dev

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

convex/lib/crpc.ts
import { initCRPC } from '../functions/generated/server';

const c = initCRPC.create();

Payload Transformer (Optional)

cRPC serializes inputs/outputs through a transformer at the procedure boundary.

  • Date support 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
convex/lib/crpc.ts
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.

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

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

Before (vanilla Convex)
convex/functions/user.ts
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);
  },
});
After (cRPC)
convex/functions/user.ts
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

TypeBuilderUse For
Queryc.queryRead-only operations, real-time subscriptions
Mutationc.mutationWrite operations, transactional updates
Actionc.actionSide effects, external API calls
HTTP Actionc.httpActionREST endpoints, webhooks

Adding Middleware

Define a middleware that checks auth and adds the user to context:

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

convex/lib/crpc.ts
export const authQuery = c.query.use(authMiddleware);
export const authMutation = c.mutation.use(authMiddleware);

ctx.user is now available in handlers:

convex/functions/user.ts
export const me = authQuery
  .output(userSchema)
  .query(async ({ ctx }) => {
    return ctx.user; // Typed! We know user exists
  });

Adding Metadata

convex/lib/crpc.ts
const c = initCRPC
  .meta<{
    auth?: 'optional' | 'required';
    role?: 'admin';
    ratelimit?: string;
  }>()
  .create();

Middleware reads metadata:

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

VariantDescriptionMiddleware
publicQueryNo auth requiredNone
optionalAuthQueryctx.user may be nullOptional auth
authQueryctx.user guaranteedAuth required
authMutationAuth + rate limitingAuth + rate limit
adminQueryAuth + admin roleAuth + 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 metadata

Client proxy mirrors this:

src/components/example.tsx
// 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, action from convex/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 v validators
  • { ctx, input } destructured params
  • Reusable procedure variants with built-in middleware

Next Step

Next Steps

On this page