Concepts
Architecture and vocabulary for Better Convex.
Overview
| Concept | What It Does |
|---|---|
| cRPC Builder | tRPC-style fluent API for defining procedures |
| Real-time First | WebSocket subscriptions flow into TanStack Query |
| Context & Middleware | Composable layers for auth, rate limiting, custom logic |
| Database Layer | Ents relationships, triggers, aggregates |
| File Structure | Organized 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:
- Fetches the initial data from Convex
- Subscribes to real-time updates via WebSocket
- Caches the result in TanStack Query's cache
| Behavior | TanStack Query Alone | Better 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 accessMiddleware 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 metadataThe 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:
{
"functions": "convex/functions",
"codegen": {
"staticApi": true,
"staticDataModel": true
},
"typescriptCompiler": "tsgo"
}| Option | Purpose |
|---|---|
functions | Directory containing your Convex functions |
codegen.staticApi | Generate static API types for better inference |
codegen.staticDataModel | Generate static data model types |
typescriptCompiler | Use "tsgo" for native TypeScript 7 support |
Static codegen improves type inference and IDE performance. The tsgo compiler is significantly faster for large codebases.