BETTER-CONVEX

Concepts

Architecture and vocabulary for kitcn.

ConceptWhat It Does
cRPC BuildertRPC-style fluent API for defining procedures
Real-time + TanStack QueryBuilt-in adapter bridges Convex subscriptions into TanStack Query
Context & MiddlewareComposable layers for auth, rate limiting, custom logic
Execution ModelQueries, mutations, actions, and scheduling
Database LayerORM relations, triggers, aggregates, migrations
PluginsCross-cutting features that span schema, runtime, and client
File StructureOrganized functions/, lib/, shared/ directories

The Problem

Convex is reactive and everything syncs in real-time. But as your app grows, friction builds up — manually wiring TanStack Query hooks, copy-pasting auth checks into every function, adding rate limiting boilerplate.

kitcn solves this. Convex's reactive database + TanStack Query's DX:

  • tRPC-style API — Fluent builder with full type inference
  • TanStack Query NativeuseQuery, useMutation, DevTools
  • Real-time by Default — WebSocket subscriptions, no extra setup
  • End-to-end Type Safety — Schema to components, everything typed
  • Middleware Chains — Auth, rate limiting, custom context

cRPC Builder

Chain methods to build procedures with validation, middleware, and type inference:

export const list = authQuery
  .input(z.object({ limit: z.number().optional() }))
  .query(async ({ ctx, input }) => {
    return ctx.orm.query.todos.findMany({ limit: input.limit ?? 10 });
  });

authQuery is a middleware-powered builder — auth runs before your handler, adds user to context, and every procedure using it gets that automatically. No copy-pasting auth checks.

Real-time + TanStack Query

Convex already has real-time subscriptions. kitcn bridges them into TanStack Query so you get WebSocket updates and the full TanStack Query API — isPending, isError, DevTools, cache invalidation, optimistic updates — without manual wiring.

With vanilla Convex, you'd use @convex-dev/react-query and wire each query with convexQuery(api.path, args). kitcn includes the adapter out of the box and generates typed query options from your procedures:

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

One line: fetches data, subscribes to WebSocket updates, caches in TanStack Query. No adapter setup, no manual query key management.

For a detailed feature-by-feature comparison with vanilla Convex, see Comparison.

Context & Middleware

Every procedure receives ctx. Middleware transforms it before your handler runs:

convex/lib/crpc.ts
import type { GenericId } from 'convex/values';
import { CRPCError } from 'kitcn/server';

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

  const userId = identity.subject as GenericId<'user'>;
  const user = await ctx.orm.query.user.findFirst({ where: { id: userId } });
  if (!user) throw new CRPCError({ code: 'UNAUTHORIZED' });
  return next({ ctx: { ...ctx, user, userId: user.id } });
});

Define once, use everywhere. Need admin-only? Extend authQuery with a role check.

Execution Model

Convex has three procedure types with distinct runtime behaviors:

TypeBest ForKey Behavior
queryRead paths, reactive UIReactive subscriptions, no side effects
mutationWrites, transactionsAtomic, retried on internal failures
actionExternal APIs, side effectsNot retried, can call external services

Mutations and actions can schedule future work via caller.schedule.after(delayMs), caller.schedule.at(timestamp), or caller.schedule.now. Cron jobs handle recurring schedules. See Scheduling.

Most Convex bugs come from picking the wrong type. Start with query, move to mutation for writes, action only for external side effects.

Database Layer

ORM Relations

const user = await ctx.orm.query.user.findFirst({
  where: { id: userId },
  with: { posts: { limit: 10, orderBy: { createdAt: 'desc' } } },
});

Triggers

Automatic side effects on data changes — denormalized counts, cascade deletes, audit logging:

convex/functions/triggers.ts
import { defineTriggers, eq } from 'kitcn/orm';
import { post, relations } from './schema';

const triggers = defineTriggers(relations, {
  user: {
    change: async (change, ctx) => {
      if (change.operation === 'delete') {
        const posts = await ctx.orm.query.post.findMany({
          where: { authorId: change.id },
          limit: 100,
        });
        for (const postRow of posts) {
          await ctx.orm.delete(post).where(eq(post.id, postRow.id));
        }
      }
    },
  },
});

Aggregates

O(log n) counts, sums, and rankings:

const count = await ctx.orm.query.user.count();
const stats = await ctx.orm.query.user.aggregate({
  where: { status: 'active' },
  _sum: { amount: true },
  _avg: { amount: true },
});

// Ranked access via rankIndex
const lb = ctx.orm.query.scores.rank('leaderboard', { where: { gameId } });
const top10 = await lb.paginate({ cursor: null, limit: 10 });

Migrations

Built-in ORM migrations for safe schema evolution:

convex/functions/migrations.ts
import { defineMigration, defineMigrationSet } from 'kitcn/orm';

const addStatus = defineMigration({
  id: '20260214_add_status',
  table: 'projects',
  up: async ({ db }) => {
    // backfill logic
  },
});

export const migrations = defineMigrationSet([addStatus]);

See ORM Migrations for the full workflow.

Plugins

Cross-cutting features that span schema, runtime, and client. Register once, everything wired:

convex/functions/schema.ts
import { defineSchema } from 'kitcn/orm';
import { ratelimitExtension } from '../lib/plugins/ratelimit/schema';

export default defineSchema(tables).extend(ratelimitExtension());

See Plugins and Rate Limiting.

File Structure

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
│   ├── orm.ts           # ORM context attachment
│   └── plugins/
│       └── ratelimit/
│           └── plugin.ts # Rate limiting policy + middleware
└── shared/              # Client-importable code
    ├── types.ts         # Shared TypeScript types
    └── api.ts          # Generated procedure metadata

Generated Artifacts

PathPurpose
convex/functions/_generated/*Convex API and data model types
convex/functions/generated/*kitcn callers and runtime contracts
convex/shared/api.tsTyped API metadata for the client

Dev loop: change schema/procedures → kitcn dev → use generated artifacts.

Configuration

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

Next Steps

On this page