BETTER-CONVEX

Concepts

Architecture and vocabulary for Better Convex.

Overview

ConceptWhat It Does
cRPC BuildertRPC-style fluent API for defining procedures
Real-time FirstWebSocket subscriptions flow into TanStack Query
Context & MiddlewareComposable layers for auth, rate limiting, custom logic
Database LayerEnts relationships, triggers, aggregates
File StructureOrganized functions/, lib/, shared/ directories

Let's explore each concept.

The Problem

You know how Convex works: you write functions, the database is reactive, and everything syncs in real-time. It's powerful. But as your application grows, you start noticing friction.

Let's say you want to use TanStack Query. You'll need to manually wire up hooks for each function, and you'll lose Convex's real-time subscriptions - or spend time implementing them yourself. Want to add authentication checks? You'll copy the same auth logic into every function. Need rate limiting? That's more boilerplate.

Before you know it, the elegant simplicity that drew you to Convex gets buried under repetitive code.

This is what Better Convex solves. It brings together the best of both worlds: Convex's reactive database with TanStack Query's developer experience. Here's what you get:

  • tRPC-style API: A fluent builder pattern for defining procedures with full type inference.
  • TanStack Query Native: First-class integration with useQuery, useMutation, and React Query DevTools.
  • Real-time by Default: Queries subscribe to WebSocket updates automatically—no extra setup.
  • End-to-end Type Safety: From your schema to your React components, everything is typed.
  • Middleware Chains: Composable layers for auth, rate limiting, and custom context.

cRPC Builder

Better Convex introduces a tRPC-style builder for defining procedures. Instead of writing standalone functions, you chain methods to build queries and mutations with validation, middleware, and type inference.

// Before: Manual validation, no middleware
export const list = query({
  args: { limit: v.optional(v.number()) },
  handler: async (ctx, args) => {
    const user = await getUser(ctx); // repeated everywhere
    if (!user) throw new Error('Unauthorized');
    return ctx.db.query('todos').take(args.limit ?? 10).collect();
  },
});

// After: Fluent API with middleware and Zod validation
export const list = authQuery
  .input(z.object({ limit: z.number().optional() }))
  .query(async ({ ctx, input }) => {
    // ctx.user is already available from middleware
    return ctx.table('todos').take(input.limit ?? 10);
  });

The auth middleware runs once, adds user to context, and every procedure that uses authQuery gets it automatically. No more copy-pasting auth checks.

Real-time First

Convex queries are reactive—when data changes, subscribers get updates. Better Convex preserves this while giving you TanStack Query's API:

const { data, isPending } = useQuery(crpc.todos.list.queryOptions({}));

This single line does three things:

  1. Fetches the initial data from Convex
  2. Subscribes to real-time updates via WebSocket
  3. Caches the result in TanStack Query's cache
BehaviorTanStack Query AloneBetter Convex
Initial fetch
Real-time updates❌ (polling)✅ (WebSocket)
Optimistic updates
DevTools

When another user creates a todo, your list updates instantly. No polling, no manual refetching. The reactive database and TanStack Query work together.

Context & Middleware

Every procedure receives a ctx object containing database access and any data added by middleware. Middleware chains let you compose reusable logic:

// Base context from cRPC setup
ctx.db        // Native Convex database
ctx.table     // Ents fluent API (if configured)
ctx.auth      // Convex auth object

// Added by auth middleware
ctx.user      // The authenticated user document
ctx.userId    // User's ID for quick access

Middleware runs before your handler and can transform context:

const authQuery = c.query.use(async ({ ctx, next }) => {
  const identity = await ctx.auth.getUserIdentity();
  if (!identity) throw new CRPCError({ code: 'UNAUTHORIZED' });

  const user = await ctx.table('user').get(identity.subject);
  return next({ ctx: { user, userId: user._id } });
});

Define the middleware once, use it everywhere. When you need admin-only access, create adminQuery that extends authQuery with a role check.

Database Layer

Better Convex works with Convex's native database, but integrates seamlessly with ecosystem tools for enhanced capabilities.

Ents

Convex Ents adds relationships and a fluent query API. Instead of manual ID lookups, you traverse edges:

// Define relationships in schema
const schema = defineEntSchema({
  user: defineEnt({ name: v.string() })
    .edges('posts', { to: 'post', ref: 'authorId' }),
  post: defineEnt({ title: v.string() })
    .edge('author', { to: 'user', field: 'authorId' }),
});

// Query with relationships
const user = await ctx.table('user').getX(userId);
const posts = await user.edge('posts');

Ents makes your database feel like an ORM while keeping Convex's reactivity. The fluent API chains naturally with Better Convex's builder pattern.

Triggers

Automatic side effects when data changes. Use convex-helpers triggers for:

  • Maintaining denormalized counts
  • Cascade deletes
  • Audit logging
  • Syncing to external systems
triggers.register('user', async (ctx, change) => {
  if (change.operation === 'delete') {
    // Cascade delete user's posts
    const posts = await ctx.table('post')
      .filter(q => q.eq(q.field('authorId'), change.id));
    for (const post of posts) {
      await ctx.table('post').delete(post._id);
    }
  }
});

Aggregates

O(log n) operations for counts, sums, and rankings. Essential for dashboards and leaderboards at scale:

// Slow: Counting all rows (O(n))
const count = await ctx.table('user').count();

// Fast: Using aggregates (O(log n))
const count = await userAggregate.count(ctx);
const topUsers = await userAggregate.rank(ctx, { limit: 10 });

Aggregates are critical when you have thousands of records. A naive count query scans every row; aggregates use tree structures for constant-time lookups.

File Structure

Better Convex follows a consistent organization pattern:

convex/
├── functions/           # Convex functions (deployed)
│   ├── _generated/      # api.ts, dataModel.ts (auto-generated)
│   ├── schema.ts        # Database schema definition
│   ├── user.ts          # User procedures
│   └── todos.ts         # Todo procedures
├── lib/                 # Shared helpers (not deployed)
│   ├── crpc.ts          # cRPC builder and middleware
│   ├── ents.ts          # Ents configuration
│   ├── triggers.ts      # Database triggers
│   └── rate-limiter.ts  # Rate limiting logic
└── shared/              # Client-importable code
    ├── types.ts         # Shared TypeScript types
    └── meta.ts          # Generated procedure metadata

The functions/ directory contains your deployed Convex code. The lib/ directory holds configuration that's imported by functions but not deployed directly. The shared/ directory contains code that's safe to import on both client and server.

Configuration

Your convex.json configures Convex codegen:

convex.json
{
  "functions": "convex/functions",
  "codegen": {
    "staticApi": true,
    "staticDataModel": true
  },
  "typescriptCompiler": "tsgo"
}
OptionPurpose
functionsDirectory containing your Convex functions
codegen.staticApiGenerate static API types for better inference
codegen.staticDataModelGenerate static data model types
typescriptCompilerUse "tsgo" for native TypeScript 7 support

Static codegen improves type inference and IDE performance. The tsgo compiler is significantly faster for large codebases.

Next Steps

On this page