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

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.

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

  1. .dataModel<DataModel>() - Enables type-safe table access via ctx.db
  2. .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.

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;

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:

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

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

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

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.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 of v validators for richer validation
  • Destructured params - Access { ctx, input } instead of separate (ctx, args)

Procedure Types

cRPC provides builders for each Convex function type:

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

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:

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.db
    .query('user')
    .withIndex('email', (q) => q.eq('email', identity.email!))
    .unique();

  return next({ ctx: { ...ctx, user } });
});

Now create an authenticated procedure variant:

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

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

convex/lib/crpc.ts
type Meta = {
  auth?: 'optional' | 'required';
  role?: 'admin';
  rateLimit?: string;
};

const c = initCRPC
  .dataModel<DataModel>()
  .meta<Meta>()
  .create({ ... });

Now middleware can check 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

You can add custom properties to the base context. This is useful for adding helpers like ctx.table from Convex Ents:

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

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

On the client, the proxy mirrors this structure:

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 _generated/server
  • File-based organization in convex/functions/
  • Export functions as named exports

What's new

Before (vanilla Convex):

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

convex/functions/user.ts
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 v validators
  • { 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:

Next Steps

On this page