# Concepts (/docs/concepts)
import { InfoIcon } from "lucide-react"
| Concept | What It Does |
| ------------------------------ | ----------------------------------------------------------------------- |
| **cRPC Builder** | tRPC-style fluent API for defining procedures |
| **Real-time + TanStack Query** | Built-in adapter bridges Convex subscriptions into TanStack Query |
| **Context & Middleware** | Composable layers for auth, rate limiting, custom logic |
| **Execution Model** | Queries, mutations, actions, and scheduling |
| **Database Layer** | ORM relations, triggers, aggregates, [migrations](/docs/orm/migrations) |
| **Plugins** | Cross-cutting features that span schema, runtime, and client |
| **File Structure** | Organized `functions/`, `lib/`, `shared/` directories |
cRPC Builder [#crpc-builder]
Chain methods to build procedures with validation, middleware, and type inference:
```ts showLineNumbers
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 [#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:
```tsx showLineNumbers {1}
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](/docs/comparison/convex).
Context & Middleware [#context--middleware]
Every procedure receives `ctx`. Middleware transforms it before your handler runs:
```ts showLineNumbers title="convex/lib/crpc.ts" {4-7}
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 } });
});
```
Execution Model [#execution-model]
Convex has three procedure types with distinct runtime behaviors:
| Type | Best For | Key Behavior |
| ---------- | --------------------------- | --------------------------------------- |
| `query` | Read paths, reactive UI | Reactive subscriptions, no side effects |
| `mutation` | Writes, transactions | Atomic, retried on internal failures |
| `action` | External APIs, side effects | Not 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](/docs/server/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 [#database-layer]
ORM Relations [#orm-relations]
```ts showLineNumbers
const user = await ctx.orm.query.user.findFirst({
where: { id: userId },
with: { posts: { limit: 10, orderBy: { createdAt: 'desc' } } },
});
```
Triggers [#triggers]
Automatic side effects on data changes — denormalized counts, cascade deletes, audit logging:
```ts showLineNumbers title="convex/functions/triggers.ts" {4-15}
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 [#aggregates]
O(log n) counts, sums, and rankings:
```ts showLineNumbers
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 [#migrations]
Built-in ORM migrations for safe schema evolution:
```ts showLineNumbers title="convex/functions/migrations.ts" {3-9}
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](/docs/orm/migrations) for the full workflow.
Plugins [#plugins]
Cross-cutting features that span schema, runtime, and client. Register once, everything wired:
```ts showLineNumbers title="convex/functions/schema.ts" {2,5}
import { defineSchema } from 'kitcn/orm';
import { ratelimitExtension } from '../lib/plugins/ratelimit/schema';
export default defineSchema(tables).extend(ratelimitExtension());
```
See [Plugins](/docs/plugins) and [Rate Limiting](/docs/plugins/ratelimit).
File Structure [#file-structure]
```plaintext showLineNumbers {2-6,7-11,12-14}
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 [#generated-artifacts]
| Path | Purpose |
| ------------------------------- | ----------------------------------- |
| `convex/functions/_generated/*` | Convex API and data model types |
| `convex/functions/generated/*` | kitcn callers and runtime contracts |
| `convex/shared/api.ts` | Typed API metadata for the client |
Dev loop: change schema/procedures → `kitcn dev` → use generated artifacts.
Configuration [#configuration]
```json title="convex.json" showLineNumbers {2,4-5,7}
{
"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 |
Next Steps [#next-steps]
# Expo (/docs/expo)
Expo Setup [#expo-setup]
Use Expo when you want a native-first starter but still want the same kitcn
backend baseline and starter messages flow.
```bash showLineNumbers
npx kitcn@latest init -t expo --yes
```
This path uses the official `create-expo-app` shell, then overlays the minimum
kitcn Convex wiring:
* `.env.local`
* `expo-env.d.ts`
* `src/components/providers.tsx`
* `src/lib/convex/*`
* `src/app/_layout.tsx`
* `src/app/index.tsx`
* `convex/functions/schema.ts`
* `convex/functions/messages.ts`
* `convex/lib/crpc.ts`
* `convex/lib/get-env.ts`
* `convex/shared/api.ts`
V1 is intentionally narrow:
* fresh scaffold only
* no existing Expo app adoption
* no styling-framework opinion beyond the official Expo baseline
Add Auth [#add-auth]
Expo now supports the default auth scaffold:
```bash showLineNumbers
npx kitcn add auth --yes
npx kitcn add auth --schema --yes
```
On Expo, `add auth` owns:
* `src/lib/convex/auth-client.ts`
* `src/lib/convex/convex-provider.tsx`
* `src/app/auth.tsx`
* `convex/functions/auth.config.ts`
* `convex/functions/auth.ts`
* auth-owned schema blocks in `convex/functions/schema.ts`
Expo auth parity follows the default auth scaffold, not the raw Convex preset.
That means Expo keeps managed schema refresh with `add auth --schema --yes`.
The generated Expo auth client uses:
* `@better-auth/expo/client`
* `expo-secure-store`
* `EXPO_PUBLIC_CONVEX_SITE_URL`
The generated auth route stays on `/auth`.
Run It [#run-it]
Start the backend in one terminal:
```bash title="terminal 1" showLineNumbers
npx kitcn dev
```
Then start Expo in another:
```bash title="terminal 2" showLineNumbers
bun run start
```
The starter app opens to a single messages screen backed by Convex. Add a
message and watch the list update.
Env Contract [#env-contract]
The Expo starter uses Expo's client env model:
```bash
EXPO_PUBLIC_CONVEX_URL=http://127.0.0.1:3210
EXPO_PUBLIC_CONVEX_SITE_URL=http://127.0.0.1:3211
EXPO_PUBLIC_SITE_URL=http://localhost:3000
```
Use `process.env.EXPO_PUBLIC_*` directly in the client. Do not destructure
those values or access them with bracket notation.
# Introduction (/docs)
import { InfoIcon } from "lucide-react"
Introduction [#introduction]
kitcn is a complete framework for Convex. It brings tRPC-style APIs, TanStack Query, a Drizzle-style ORM, and Better Auth into one cohesive developer experience.
**This is not just a query wrapper.** It's how you build production Convex applications.
Philosophy [#philosophy]
kitcn integrates Convex for realtime data, TanStack Query for caching, and Better Auth for authentication behind a consistent API:
* **Type safety end-to-end** - From schema to procedures to client, with full inference
* **Real-time by default** - WebSocket subscriptions flow into TanStack Query cache
* **TanStack Query native** - Use `useQuery` and `useMutation` directly with cRPC options
* **Incremental adoption** - Add features as you need them, migrate function by function
What's Included [#whats-included]
| Export | Description | Depends on |
| ------------------------- | ------------------------------------------ | ------------------------- |
| `kitcn` (CLI) | Codegen, `kitcn dev` | — |
| `kitcn/server` | cRPC builder, middleware, server caller | — |
| `kitcn/orm` | Drizzle-style ORM schema and query helpers | — |
| `kitcn/react` | React client, providers, auth hooks | `server` |
| `kitcn/rsc` | RSC prefetching, hydration | `server`, `react`, `next` |
| `kitcn/auth` | Better Auth adapter | — |
| `kitcn/auth/client` | Better Auth client plugins | `react` |
| `kitcn/auth/config` | Convex auth config provider | `auth` |
| `kitcn/auth/http` | Auth HTTP helpers | `auth` |
| `kitcn/auth/nextjs` | Better Auth Next.js adapter | `server`, `next` |
| `kitcn/auth/start` | TanStack Start loader auth sync | `auth` |
| `kitcn/auth/start/server` | Better Auth TanStack Start server adapter | `auth` |
| `kitcn/ratelimit` | Rate limiting plugin | `orm` |
`server`, `orm`, and `auth` are standalone foundations — everything else builds on one or more. You can use `server` or `auth` without `orm`, `orm` and `plugins` without `server`.
Quick Look [#quick-look]
```tsx showLineNumbers {2-5,8}
// Server: Define a procedure
export const list = authQuery
.input(z.object({ limit: z.number().optional() }))
.query(async ({ ctx, input }) => {
return ctx.orm.query.posts.findMany({ limit: input.limit ?? 10 });
});
// Client: Use it with TanStack Query
const { data: posts } = useQuery(crpc.posts.list.queryOptions({ limit: 10 }));
```
Requirements [#requirements]
* Convex
* TanStack Query 5+
* React 18+
Agents [#agents]
Use the `kitcn` skill instead of reading these docs as your primary source.
If the `kitcn` skill is not already installed, run:
```bash
npx skills add https://github.com/udecode/kitcn --skill kitcn
```
Then load the `kitcn` skill and follow its setup or feature references. Use
these docs only when the skill points to a page for deeper API detail.
Next Steps [#next-steps]
# Quickstart (/docs/quickstart)
import { InfoIcon } from "lucide-react"
}>
If you want the full command map, start with
[/docs/cli/registry](/docs/cli/registry). This guide stays on the shortest
path.
}>
Docs use the ORM (`ctx.orm`) everywhere. The scaffolded starter demo keeps the
backend tiny on purpose, so it uses direct Convex reads and writes. Once the
app is running, you can move deeper into the ORM guides and migration docs.
1. Scaffold A Fresh App [#1-scaffold-a-fresh-app]
```bash showLineNumbers
mkdir my-app
cd my-app
```
`init -t next` creates the Next.js shell and layers in the kitcn
baseline in one pass. That includes the backend files, client providers, local
env scaffolding, and a tiny messages demo so you have something real to run
immediately.
Fresh TanStack Start, Vite, and Expo starters are also available through
`kitcn init -t start --yes`, `kitcn init -t vite --yes`, and
`kitcn init -t expo --yes`.
If you want the native-first path, use [/docs/expo](/docs/expo) instead of
forcing the Next quickstart into a mobile app.
Here are the key files the quickstart uses:
| File | Purpose |
| ------------------------------ | -------------------------- |
| `convex/functions/schema.ts` | Starter schema |
| `convex/functions/messages.ts` | Starter query and mutation |
| `convex/lib/crpc.ts` | Server-side cRPC builders |
| `lib/convex/crpc.tsx` | React cRPC context |
| `components/providers.tsx` | App-level providers |
| `app/convex/page.tsx` | Starter demo page |
If you already have a supported app shell, use `npx kitcn@latest init --yes`
instead. That adoption path is documented in
[/docs/cli/registry](/docs/cli/registry).
2. Run The App [#2-run-the-app]
Start the backend and open the Next.js app in a second terminal.
```bash title="terminal 1" showLineNumbers
npx kitcn dev
```
```bash title="terminal 2" showLineNumbers
npm run dev
```
Open [http://localhost:3000/convex](http://localhost:3000/convex). You should
see the starter messages page. Add a message and watch the list update.
That gives you the full end-to-end loop:
* query from React
* mutation from React
* live backend round-trip
* generated API metadata through `@convex/api`
}>
If the page says the backend is not ready, check the terminal running
`kitcn dev`. The starter page already includes that empty/error state,
so you do not have to guess what failed.
3. Read The Starter Backend [#3-read-the-starter-backend]
Schema [#schema]
The starter schema is intentionally tiny.
```ts title="convex/functions/schema.ts" showLineNumbers {1-7}
import { convexTable, defineSchema, text } from 'kitcn/orm';
export const messagesTable = convexTable('messages', {
body: text().notNull(),
});
export const tables = {
messages: messagesTable,
};
export default defineSchema(tables);
```
This gives the demo one `messages` table with a single required `body` field.
That is enough to prove the full query and mutation loop without burying the
quickstart in schema noise.
Procedures [#procedures]
The starter procedures:
```ts title="convex/functions/messages.ts" showLineNumbers {5-17,19-30}
import * as z from 'zod';
import { publicMutation, publicQuery } from '../lib/crpc';
export const list = publicQuery
.output(
z.array(
z.object({
id: z.string(),
body: z.string(),
createdAt: z.date(),
})
)
)
.query(async ({ ctx }) => {
const rows = await ctx.db.query('messages').order('desc').take(10);
return rows.map((row) => ({
id: row._id,
body: row.body,
createdAt: new Date(row._creationTime),
}));
});
export const create = publicMutation
.input(z.object({ body: z.string().trim().min(1).max(120) }))
.output(z.string())
.mutation(async ({ ctx, input }) =>
await ctx.db.insert('messages', { body: input.body })
);
```
The pattern is simple:
* `publicQuery` reads data
* `publicMutation` writes data
* `zod` defines the contract
* the return value is shaped for the client immediately
The starter demo uses direct `ctx.db` calls because they are easy to read. The
rest of the docs build on top of `ctx.orm` once you need richer schemas,
relations, and typed query helpers.
cRPC builder [#crpc-builder]
The builders come from the scaffolded cRPC file:
```ts title="convex/lib/crpc.ts" showLineNumbers {1-10}
import { initCRPC } from '../functions/generated/server';
const c = initCRPC.create();
export const publicQuery = c.query;
export const publicAction = c.action;
export const publicMutation = c.mutation;
export const privateQuery = c.query.internal();
export const privateMutation = c.mutation.internal();
export const privateAction = c.action.internal();
```
That file is part of the baseline. You do not need to hand-roll cRPC setup in
the quickstart. `init` already gave you the builders the starter app needs.
4. Read The Starter Client [#4-read-the-starter-client]
The client side is just as small.
React cRPC context [#react-crpc-context]
The generated client context:
```tsx title="lib/convex/crpc.tsx" showLineNumbers {1-6}
import { api } from '@convex/api';
import { createCRPCContext } from 'kitcn/react';
export const { CRPCProvider, useCRPC, useCRPCClient } = createCRPCContext({
api,
convexSiteUrl: process.env.NEXT_PUBLIC_CONVEX_SITE_URL!,
});
```
`@convex/api` is the generated metadata surface. `createCRPCContext(...)`
turns that into typed TanStack Query helpers for your React app.
Demo page [#demo-page]
The starter page:
```tsx title="app/convex/page.tsx" showLineNumbers {9-18,34-47,58-74}
'use client';
import { useMutation, useQuery } from '@tanstack/react-query';
import { type FormEvent, useState } from 'react';
import { Button } from '@/components/ui/button';
import { useCRPC } from '@/lib/convex/crpc';
export default function ConvexMessagesPage() {
const crpc = useCRPC();
const [draft, setDraft] = useState('');
const messagesQuery = useQuery(crpc.messages.list.queryOptions());
const createMessage = useMutation(crpc.messages.create.mutationOptions());
async function handleSubmit(event: FormEvent) {
event.preventDefault();
const body = draft.trim();
if (!body) return;
try {
await createMessage.mutateAsync({ body });
setDraft('');
} catch {}
}
return (
{messagesQuery.isPending ? (
Loading messages...
) : messagesQuery.isError ? (
Backend not ready. Start kitcn dev and refresh.
) : (
{messagesQuery.data.map((message) => (
{message.body}
))}
)}
);
}
```
This is the starter loop you will reuse everywhere:
* `useCRPC()` gives you typed procedure handles
* `useQuery(...)` reads data from a query procedure
* `useMutation(...)` calls a mutation procedure
* the UI handles loading, error, and success states in the same file
5. What To Build Next [#5-what-to-build-next]
From here, pick the next seam you actually need:
# TanStack Start (/docs/tanstack-start)
import { InfoIcon } from "lucide-react";
In this guide, we'll scaffold a fresh TanStack Start app with kitcn, layer in
auth with the CLI, and then walk through the generated files so you know what
the scaffold actually wrote. This page stays on the CLI path first. The file
snippets later in the guide are reference output, not a separate manual install
flow.
1. Scaffold [#1-scaffold]
```bash showLineNumbers
mkdir my-app
cd my-app
```
`init -t start` creates the shadcn TanStack Start shell and layers in the kitcn baseline — backend files, client providers, local env scaffolding, and a starter messages demo.
| File | Purpose |
| ------------------------------------ | --------------------------------- |
| `convex/functions/schema.ts` | Starter schema |
| `convex/functions/messages.ts` | Starter query and mutation |
| `convex/lib/crpc.ts` | Server-side cRPC builders |
| `src/lib/convex/crpc.tsx` | React cRPC context |
| `src/lib/convex/convex-provider.tsx` | Convex + TanStack Query providers |
| `src/lib/convex/query-client.ts` | QueryClient with hydration config |
| `src/components/providers.tsx` | App-level providers |
2. Add Auth [#2-add-auth]
Next, we'll add auth on top of the generated baseline. Keep using the published
CLI runner for your package manager here as well.
This scaffolds the auth layer on top of the baseline:
| File | Purpose |
| ------------------------------- | ----------------------------------------------- |
| `src/lib/convex/auth-client.ts` | Better Auth client with `convexClient()` plugin |
| `src/lib/convex/auth-server.ts` | Server auth helpers (`handler`, `getToken`) |
| `src/lib/convex/server.ts` | Caller factory with auth token support |
| `src/routes/api/auth/$.ts` | Auth API endpoint |
| `convex/functions/auth.ts` | Auth config and plugin registration |
| `convex/functions/http.ts` | HTTP route with auth wired |
3. Run [#3-run]
Open two terminals. In the first terminal, start the backend with the published
CLI runner for your package manager:
In the second terminal, start the app with your package manager's dev script:
```bash title="terminal 2"
npm run dev
# or pnpm dev
# or yarn dev
# or bun run dev
```
The TanStack Start app serves on `http://localhost:3000` by default. `kitcn dev`
starts the local Convex backend and keeps codegen up to date while you work.
4. Generated Files [#4-generated-files]
The rest of this page is a reference for the files `kitcn add auth --yes`
generates. We'll start with the auth client and server helpers, then move
through the route and provider wiring.
}>
**Important:** If you are following the CLI path, you should not need to type
these files by hand. Use them to verify the scaffold output or to understand
what each generated file is responsible for.
Auth Client [#auth-client]
```ts showLineNumbers title="src/lib/convex/auth-client.ts"
import { createAuthClient } from 'better-auth/react';
import { convexClient } from 'kitcn/auth/client';
import { createAuthMutations } from 'kitcn/react';
export const authClient = createAuthClient({
baseURL:
typeof window === 'undefined'
? (import.meta.env.VITE_SITE_URL as string | undefined)
: window.location.origin,
plugins: [convexClient()],
});
export const {
useSignInMutationOptions,
useSignOutMutationOptions,
useSignUpMutationOptions,
} = createAuthMutations(authClient);
```
Auth Server [#auth-server]
```ts showLineNumbers title="src/lib/convex/auth-server.ts"
import { api } from '@convex/api';
import { convexBetterAuthReactStart } from 'kitcn/auth/start/server';
export const {
handler,
getToken,
createCaller,
createContext,
fetchAuthQuery,
fetchAuthMutation,
fetchAuthAction,
} = convexBetterAuthReactStart({
api,
convexUrl: import.meta.env.VITE_CONVEX_URL!,
convexSiteUrl: import.meta.env.VITE_CONVEX_SITE_URL!,
});
```
`getToken` fetches the Convex token from your auth route. Set
`auth.jwtCache: true` to read it from the session cookie instead, saving the
round trip. Leave it off when you prime the browser Convex client from
`getToken()` — a cookie token can be close to expiry, and the browser client
cannot renew the value it captures.
Loader Auth [#loader-auth]
Server functions and server loaders should call Convex through the `caller`
or the `fetchAuthQuery` helper from `auth-server.ts`. Client-side route loaders
that use the shared TanStack Query client can prime the shared Convex client
before child loaders run:
```tsx showLineNumbers title="src/routes/__root.tsx"
import type { QueryClient } from '@tanstack/react-query';
import {
Outlet,
createRootRouteWithContext,
redirect,
} from '@tanstack/react-router';
import { createServerFn } from '@tanstack/react-start';
import { syncConvexAuthForStartLoader } from 'kitcn/auth/start';
import type { ConvexQueryClient } from 'kitcn/react';
import { Providers } from '@/components/providers';
import { getToken } from '@/lib/convex/auth-server';
const getLoaderToken = createServerFn({ method: 'GET' }).handler(async () => {
return await getToken();
});
export const Route = createRootRouteWithContext<{
convexQueryClient: ConvexQueryClient;
queryClient: QueryClient;
}>()({
beforeLoad: async ({ context }) => {
const auth = await syncConvexAuthForStartLoader({
convex: context.convexQueryClient,
getToken: getLoaderToken,
});
return auth;
},
component: RootComponent,
});
export const requireAuth = (context: { isAuthenticated: boolean }) => {
if (!context.isAuthenticated) {
throw redirect({ to: '/auth' });
}
};
function RootComponent() {
return (
);
}
```
`syncConvexAuthForStartLoader` accepts either a `ConvexReactClient` or the
`ConvexQueryClient` from router context. It updates the browser Convex client,
updates the server HTTP client during SSR, skips duplicate `setAuth` calls for
the same token, and clears auth when no token is present.
Auth API Endpoint [#auth-api-endpoint]
```ts showLineNumbers title="src/routes/api/auth/$.ts"
import { createFileRoute } from '@tanstack/react-router';
import { handler } from '@/lib/convex/auth-server';
export const Route = createFileRoute('/api/auth/$' as never)({
server: {
handlers: {
GET: ({ request }) => handler(request),
POST: ({ request }) => handler(request),
},
},
});
```
cRPC Context [#crpc-context]
```tsx showLineNumbers title="src/lib/convex/crpc.tsx"
import { api } from '@convex/api';
import { createCRPCContext } from 'kitcn/react';
export const { CRPCProvider, useCRPC, useCRPCClient } = createCRPCContext({
api,
convexSiteUrl: import.meta.env.VITE_CONVEX_SITE_URL!,
});
```
Providers [#providers]
```tsx showLineNumbers title="src/lib/convex/convex-provider.tsx"
'use client';
import { QueryClientProvider as TanstackQueryClientProvider } from '@tanstack/react-query';
import { ConvexAuthProvider } from 'kitcn/auth/client';
import {
ConvexReactClient,
getConvexQueryClientSingleton,
getQueryClientSingleton,
} from 'kitcn/react';
import type { ReactNode } from 'react';
import { authClient } from '@/lib/convex/auth-client';
import { CRPCProvider } from '@/lib/convex/crpc';
import { createQueryClient } from '@/lib/convex/query-client';
const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL!);
export function AppConvexProvider({
children,
}: {
children: ReactNode;
}) {
const queryClient = getQueryClientSingleton(createQueryClient);
const convexQueryClient = getConvexQueryClientSingleton({
convex,
queryClient,
});
return (
{children}
);
}
```
Server Caller [#server-caller]
`createCaller()` binds a caller to the current request. Every procedure call in
a request shares one Convex auth token, so the token is fetched at most once per
request no matter how many procedures you call:
```ts showLineNumbers title="src/lib/convex/server.ts"
import { createCaller } from '@/lib/convex/auth-server';
export const caller = createCaller();
```
Usage:
```ts showLineNumbers title="src/functions/get-current-user.ts"
import { caller } from '@/lib/convex/server';
import { createServerFn } from '@tanstack/react-start';
export const getCurrentUser = createServerFn({ method: 'GET' }).handler(
async () => {
return await caller.user.getSessionUser({});
},
);
```
The module-scope `caller` holds no request state. It resolves the current
request through TanStack Start's request scope on each call, so tokens are never
shared between requests.
Reach `caller` from a `createServerFn` handler or a server route. A route
`loader` also runs in the browser on client-side navigation, where there is no
request scope.
To call Convex with headers other than the current request's, pass your own
context factory:
```ts
const caller = createCaller(() => createContext({ headers }));
```
Root Route [#root-route]
The scaffold uses a simple root route — providers wrap the outlet:
```tsx showLineNumbers title="src/routes/__root.tsx"
import {
HeadContent,
Outlet,
Scripts,
createRootRoute,
} from '@tanstack/react-router';
import { Providers } from '@/components/providers';
import appCss from '../styles.css?url';
export const Route = createRootRoute({
head: () => ({
meta: [
{ charSet: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
{ title: 'TanStack Start Starter' },
],
links: [{ rel: 'stylesheet', href: appCss }],
}),
component: RootComponent,
shellComponent: RootDocument,
});
function RootDocument({ children }: { children: React.ReactNode }) {
return (
{children}
);
}
function RootComponent() {
return (
);
}
```
}>
If you see an error about a missing `VITE_` environment variable, add it to your local env file.
Next Steps [#next-steps]
# Client (/docs/auth/client)
import { InfoIcon } from "lucide-react"
Setup [#setup]
Create an auth client with mutation hooks:
```ts title="src/lib/convex/auth-client.ts" showLineNumbers {4,8-10,13-17}
import { inferAdditionalFields } from 'better-auth/client/plugins';
import { createAuthClient } from 'better-auth/react';
import { convexClient } from 'kitcn/auth/client';
import { createAuthMutations } from 'kitcn/react';
import type { Auth } from '@convex/auth-shared';
export const authClient = createAuthClient({
baseURL: process.env.NEXT_PUBLIC_SITE_URL!,
plugins: [inferAdditionalFields(), convexClient()],
});
export const {
useSignInMutationOptions,
useSignInSocialMutationOptions,
useSignOutMutationOptions,
useSignUpMutationOptions,
} = createAuthMutations(authClient);
```
On Next.js, the scaffolded auth route lives on the same origin as the page, so
the client does not need an explicit `baseURL`. Add one only when your auth
handler lives on a different origin.
Sign In [#sign-in]
Social Providers [#social-providers]
```tsx title="src/components/login-form.tsx" showLineNumbers {6,8-11,13-16}
'use client';
import { useMutation } from '@tanstack/react-query';
import { useSignInSocialMutationOptions } from '@/lib/convex/auth-client';
function LoginForm() {
const signInSocial = useMutation(useSignInSocialMutationOptions());
const handleGoogleSignIn = () => {
signInSocial.mutate({
callbackURL: window.location.origin,
provider: 'google',
});
};
const handleGithubSignIn = () => {
signInSocial.mutate({
callbackURL: window.location.origin,
provider: 'github',
});
};
return (
);
}
```
Email/Password [#emailpassword]
First enable email/password in your Convex auth config:
```ts title="convex/functions/auth.ts" showLineNumbers {1,4-8}
import { defineAuth } from './generated/auth';
export default defineAuth((ctx) => ({
emailAndPassword: {
enabled: true,
},
// ... rest of config
}));
```
Then use the sign in/up hooks:
```tsx title="src/components/login-form.tsx" showLineNumbers {8-9,13-20,22-39}
'use client';
import { useMutation } from '@tanstack/react-query';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import {
useSignInMutationOptions,
useSignUpMutationOptions,
} from '@/lib/convex/auth-client';
function EmailLoginForm() {
const [mode, setMode] = useState<'signin' | 'signup'>('signin');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [name, setName] = useState('');
const router = useRouter();
const signIn = useMutation(
useSignInMutationOptions({
onSuccess: () => router.push('/'),
})
);
const signUp = useMutation(
useSignUpMutationOptions({
onSuccess: () => router.push('/'),
})
);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (mode === 'signup') {
signUp.mutate({
callbackURL: window.location.origin,
email,
name,
password,
});
} else {
signIn.mutate({
callbackURL: window.location.origin,
email,
password,
});
}
};
const isPending = signIn.isPending || signUp.isPending;
return (
);
}
```
}>
Unlike OAuth (which redirects server-side), email/password auth requires a client-side redirect via `onSuccess`.
Plugin Sign-In Methods [#plugin-sign-in-methods]
Better Auth client plugins can add sign-in methods under `authClient.signIn`.
Set `signInMethod` when the mutation should call one of those methods:
```tsx title="src/components/username-login-form.tsx" showLineNumbers {7-10,13-18}
'use client';
import { useMutation } from '@tanstack/react-query';
import { useRouter } from 'next/navigation';
import { useSignInMutationOptions } from '@/lib/convex/auth-client';
function UsernameLoginForm() {
const router = useRouter();
const signIn = useMutation(
useSignInMutationOptions({
signInMethod: 'username',
onSuccess: () => router.push('/'),
})
);
const handleSubmit = (username: string, password: string) => {
signIn.mutate({
callbackURL: window.location.origin,
password,
username,
});
};
}
```
Sign Out [#sign-out]
```tsx title="src/components/logout-button.tsx" showLineNumbers {6,8-12,15}
'use client';
import { useMutation } from '@tanstack/react-query';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
import { useSignOutMutationOptions } from '@/lib/convex/auth-client';
function LogoutButton() {
const router = useRouter();
const signOut = useMutation(
useSignOutMutationOptions({
onSuccess: () => router.push('/login'),
onError: () => toast.error('Failed to sign out'),
})
);
return (
);
}
```
Why createAuthMutations? [#why-createauthmutations]
The hooks provide two key features:
| Feature | Description |
| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Auth query cleanup | `useSignOutMutationOptions` automatically calls `unsubscribeAuthQueries()` before `signOut()` to prevent `UNAUTHORIZED` errors from subscribed queries during logout |
| Proper loading state | The mutation's `isPending` stays `true` until the auth token is actually cleared (not just when the API call completes), preventing UI flicker |
Client Hooks [#client-hooks]
useAuth [#useauth]
Get comprehensive auth state:
```tsx title="src/components/auth-status.tsx" showLineNumbers {1,4,6}
import { useAuth } from 'kitcn/react';
function AuthStatus() {
const { hasSession, isAuthenticated, isLoading } = useAuth();
if (isLoading) return ;
return (
{isAuthenticated ? 'Logged in' : 'Logged out'}
);
}
```
| Property | Description |
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `hasSession` | Has a session token (may not be verified) |
| `isAuthenticated` | Token exists AND Convex auth verified. With `optimisticAuth`, also `true` for a held, unexpired JWT before Convex confirms it; the server still enforces auth |
| `isLoading` | Convex auth is still loading |
useMaybeAuth [#usemaybeauth]
Check if user maybe has auth (optimistic, has token):
```tsx showLineNumbers {1,4}
import { useMaybeAuth } from 'kitcn/react';
function Component() {
const isAuth = useMaybeAuth();
return isAuth ? : ;
}
```
useIsAuth [#useisauth]
Check if user is authenticated (server-verified; with `optimisticAuth`, also during the optimistic window, while the server still enforces auth):
```tsx showLineNumbers {1,4}
import { useIsAuth } from 'kitcn/react';
function SecureComponent() {
const isAuth = useIsAuth();
return isAuth ? : ;
}
```
useAuthGuard [#useauthguard]
Guard mutations that require authentication:
```tsx title="src/components/create-post.tsx" showLineNumbers {1,5,8-9,12}
import { useAuthGuard } from 'kitcn/react';
import { useMutation } from '@tanstack/react-query';
function CreatePostButton() {
const guard = useAuthGuard();
const createPost = useMutation(crpc.post.create.mutationOptions());
const handleClick = () => {
// Returns true if blocked (not authenticated)
if (guard()) return;
// User is authenticated, safe to mutate
createPost.mutate({ title: 'New Post' });
};
return ;
}
```
With callback:
```tsx showLineNumbers {1-6}
const handleClick = () => {
guard(async () => {
// Only runs if authenticated
await createPost.mutateAsync({ title: 'New Post' });
toast.success('Post created!');
});
};
```
useConvexAuthRecovery [#useconvexauthrecovery]
Use `useConvexAuthRecovery()` when the outer auth provider still has a valid
session but Convex became unauthenticated after a transient token refresh
failure. `recover()` replaces the provider-owned auth binding and resolves only
after Convex confirms authentication.
```tsx title="src/components/reconnect-auth.tsx" showLineNumbers {3,6,11-14}
'use client';
import { useConvexAuthRecovery } from 'kitcn/react';
export function ReconnectAuth() {
const { error, recover, status } = useConvexAuthRecovery();
return (
{error &&
{error.message}
}
);
}
```
Concurrent calls share one promise. Pass `{ timeoutMs }` to override the
10-second timeout. Failures reject with `ConvexAuthRecoveryError` and one of
these codes: `AUTH_PROVIDER_LOADING`, `AUTH_PROVIDER_UNAUTHENTICATED`,
`AUTH_RECOVERY_CANCELLED`, `AUTH_RECOVERY_FAILED`, or
`AUTH_RECOVERY_TIMEOUT`. Do not invoke recovery for intentional sign-out.
Conditional Rendering [#conditional-rendering]
MaybeAuthenticated [#maybeauthenticated]
Render children only when has session (optimistic):
```tsx showLineNumbers {1,5-7}
import { MaybeAuthenticated } from 'kitcn/react';
function App() {
return (
);
}
```
Authenticated [#authenticated]
Render children only when server-verified (with `optimisticAuth`, also during the optimistic window):
```tsx showLineNumbers {1,5-7}
import { Authenticated } from 'kitcn/react';
function App() {
return (
);
}
```
MaybeUnauthenticated [#maybeunauthenticated]
Render children only when no session (optimistic):
```tsx showLineNumbers {1,5-11}
import { MaybeAuthenticated, MaybeUnauthenticated } from 'kitcn/react';
function App() {
return (
<>
>
);
}
```
Unauthenticated [#unauthenticated]
Render children only when not server-verified (waits for loading):
```tsx showLineNumbers {1,5-7}
import { Unauthenticated } from 'kitcn/react';
function App() {
return (
);
}
```
Provider Configuration [#provider-configuration]
Configure auth callbacks in the provider:
```tsx title="src/app.tsx" showLineNumbers {1,5-14}
import { ConvexAuthProvider } from 'kitcn/auth/client';
function App() {
return (
window.location.reload()}
onMutationUnauthorized={() => {
// Custom handler for unauthorized mutations
openLoginModal();
}}
onQueryUnauthorized={({ queryName }) => {
// Custom handler for unauthorized queries
console.log(`Unauthorized query: ${queryName}`);
}}
>
{children}
);
}
```
Props [#props]
| Prop | Type | Description | |
| ------------------------- | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| `client` | `ConvexReactClient` | Convex client instance | |
| `authClient` | `AuthClient` | Better Auth client instance | |
| `convexQueryClient` | `ConvexQueryClient?` | Shared TanStack Query client bridge | |
| `initialToken` | `string?` | Initial session token (from SSR) | |
| `optimisticAuth` | `boolean?` | Opens auth-bound query gates for a held, unexpired JWT while Convex confirms it. Defaults to `false`. | |
| `onTokenIdentityChange` | `() => void` | Enables the document identity guard. Called after the client is closed when a token changes JWT `sub` or `sessionId`. Reload the document here. | |
| `tokenIdentityBaseline` | `string \| null \| (() => string \| null)` | Optional \`sub | sessionId`identity owned by the document. A getter is checked for every token admission. Requires`onTokenIdentityChange\`. |
| `onTokenIdentityAdmitted` | `(token: string) => void` | Called for every decodable token admitted by the identity guard, including cached tokens. Requires `onTokenIdentityChange`. | |
| `onMutationUnauthorized` | `() => void` | Called when mutation is blocked | |
| `onQueryUnauthorized` | `({ queryName }) => void` | Called when query is blocked | |
Optimistic auth and document identity [#optimistic-auth-and-document-identity]
`optimisticAuth` removes the client-side confirmation wait for a held,
unexpired JWT. Convex still processes authentication before queries on the
same connection. An expired, opaque, or refused token never opens the gate.
The window lasts until the Convex client reports its first auth result; after
that, for the client's lifetime, the gate follows Convex's confirmed state.
Use one `optimisticAuth` setting for every provider over a Convex client
(results reported before an optimistic provider mounts are not seen), and
expect no optimism over a client the TanStack Start loader already
authenticated.
Use `onTokenIdentityChange` when one document must never send queued work under
a different user or session. The provider compares the JWT `sub` and
`sessionId`, refuses a mismatch before caching or forwarding it, closes the
Convex client, and calls the callback. HTTP cRPC requests use the same guarded
token source. What the guard guarantees:
1. A JWT of another user or session (whatever its `exp`) is never cached,
published, or handed to Convex, cRPC HTTP or the TanStack Start loader.
Once an identity is established, a JWT without one is refused too, and an
opaque session token is only exchanged for a JWT, never handed to Convex.
2. Admission rechecks the current baselines after `onTokenIdentityAdmitted`
returns, before publishing or handing out the token.
3. When a trip happens (any provider or the Start loader refuses such a
token), every mounted provider hands out no token and publishes
`isAuthenticated: false`; each one with `onTokenIdentityChange` closes its
client and calls it once. The trip covers the page in the browser (never
the server): providers and clients mounted later start tripped, a guarded
provider that mounts or shows again on a tripped page also closes its
client and calls `onTokenIdentityChange` once (so a trip that happened
while none was mounted still reaches the app). Enabling the guard after a
trip also closes the client and calls back once. Sign-in mutations fail
with `TOKEN_IDENTITY_CHANGED` until the reload.
The guard covers the token kitcn supplies, not an `Authorization` header the
app sets in `httpOptions.headers`.
For multiple provider mounts in one document, pass the shared identity as
`tokenIdentityBaseline`. Its format is `sub|sessionId`. Every admission (the
SSR token, fresh and cached tokens, sign-in tokens, HTTP headers and the Start
loader) holds a token to the page identity (the first one a provider knew),
the provider's own baseline, and the current answer of every mounted
provider's getter. Held tokens are reconciled when a provider joins the page
and at every admission, so a provider that mounts with an SSR token of
another identity trips the page. A page that never enables
`onTokenIdentityChange` is unchanged. Once a guarded provider establishes the
page identity, it persists until the reload and binds every provider (with or
without the option) and the Start loader, even after that provider unmounts.
Two kitcn versions or revisions on one page (including dev HMR across
revisions) are unsupported: their entries share no page identity until the
reload, and an entry that finds page state it cannot read refuses its guarded
tokens. Use `onTokenIdentityAdmitted` to observe admitted
tokens and update the shared document identity.
Auth Flow [#auth-flow]
SSR → client hydration: `getToken()` reads cookie → prefetch queries with token → pass `initialToken` to client → `ConvexAuthProvider` mounts with token → Convex validates JWT → `AuthStateSync` updates `isAuthenticated` → `HydrationBoundary` hydrates prefetched data.
| Concept | Detail |
| --------------------------- | ----------------------------------------------------------- |
| Token flows server → client | Via `initialToken` prop |
| Instant hydration | Prefetched queries hydrate immediately — no loading spinner |
| Defensive isLoading | Prevents `UNAUTHORIZED` errors during hydration race |
| Two sources sync | Better Auth (cookie-based) and Convex (WebSocket-based) |
React Native / @convex-dev/auth Users [#react-native--convex-devauth-users]
If you're using `@convex-dev/auth` (common in React Native) instead of better-auth, import `ConvexProviderWithAuth` from `kitcn/react`:
```tsx title="App.tsx" showLineNumbers {1}
import { ConvexProviderWithAuth } from 'kitcn/react';
function App() {
return (
);
}
```
This enables `skipUnauth` queries, `useAuth`, conditional rendering components,
and `useConvexAuthRecovery` to work with `@convex-dev/auth`.
Next Steps [#next-steps]
# Overview (/docs/auth)
import { InfoIcon } from "lucide-react"
kitcn integrates [Better Auth](https://better-auth.com) with Convex.
Key Concepts [#key-concepts]
Reading the session [#reading-the-session]
In queries and mutations — including the shared cRPC auth middleware — read the session row
directly:
```ts showLineNumbers {2}
import { getSession } from "kitcn/auth";
const session = await getSession(ctx);
```
Context-Aware Adapter [#context-aware-adapter]
Reach for `getAuth(ctx)` in the modules that call `auth.api.*` — actions, HTTP routes, and
organization/admin mutations. The generated runtime handles adapter selection:
* direct DB adapter for queries/mutations (`ctx.db` available)
* HTTP adapter for actions/HTTP contexts (`ctx.runQuery`/`ctx.runMutation`)
}>
**Bundle:** Convex has no dynamic `import()`, so every module that names `getAuth` carries your
whole Better Auth definition and all its plugins in its evaluation closure. Keep it out of
`convex/lib/crpc.ts`, which every procedure module imports. Authenticated actions build from
`convex/lib/crpc-action.ts`, the one builder module that imports it.
Authentication Flow [#authentication-flow]
Every request - whether from SSR or client - goes through the same two-step validation process:
```
┌─────────────────────────────────────────────────────────────────────────┐
│ REQUEST (SSR or WebSocket) │
└─────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ STEP 1: JWT Validation (cryptographic) │
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │ • Decode JWT payload │ │
│ │ • Verify signature using JWKS keys │ │
│ │ • Check exp claim (is token expired?) │ │
│ │ • Extract sessionId, userId from claims │ │
│ └───────────────────────────────────────────────────────────────────┘ │
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │ Static JWKS (recommended) │ Dynamic JWKS │ │
│ │ Keys embedded in env var │ Fetched from auth server │ │
│ │ → Instant verification │ → +100-400ms per request │ │
│ └───────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ STEP 2: Session Lookup (database) │
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │ Query: session.id = sessionId AND expiresAt > now │ │
│ └───────────────────────────────────────────────────────────────────┘ │
│ • Session found & valid → ✅ Request proceeds │
│ • Session not found → ❌ 401 (revoked or never existed) │
│ • Session expired → ❌ 401 (natural expiration) │
└─────────────────────────────────────────────────────────────────────────┘
```
This two-step process is important: **Static JWKS speeds up Step 1, but Step 2 always hits the database.** That's why session revocation works immediately - even if the JWT is cryptographically valid, the session lookup fails.
JWT vs Session [#jwt-vs-session]
| Component | Storage | Can Be Invalidated? | Default Lifetime | Purpose |
| ----------- | --------------- | ------------------- | ---------------- | ----------------------------- |
| **JWT** | Cookie (signed) | ❌ No (stateless) | 15 minutes | Fast identity verification |
| **Session** | Convex DB | ✅ Yes (stateful) | 30 days | Authorization source of truth |
The JWT is like a temporary badge - it proves who you are, but can't be revoked. The session is the master record - delete it, and the badge becomes useless.
SSR vs Client: Same Validation, Different Transport [#ssr-vs-client-same-validation-different-transport]
Both SSR and client requests go through the same two-step validation above. The difference is how they get there:
| | SSR (HTTP) | Client (WebSocket) |
| --------------------- | --------------------------------------------- | ------------------------------------------ |
| **Transport** | HTTP request per query | Persistent connection |
| **Token source** | Cookie or fetch from `/api/auth/convex/token` | Sent during WebSocket handshake |
| **Validation timing** | Per request | Once at connection, then cached |
| **JWKS impact** | +100-400ms per request (if dynamic) | +100-400ms blocking handshake (if dynamic) |
}>
**Why Static JWKS matters:** With dynamic JWKS, WebSocket queries are blocked for 100-400ms while Convex fetches signing keys. With static JWKS, validation is instant. This is especially noticeable on page load when multiple queries fire at once.
All Auth States [#all-auth-states]
What happens in each scenario (note how Step 1 and Step 2 interact):
| Scenario | Step 1: JWT | Step 2: Session | Result | User Experience |
| -------------------------------- | ----------- | --------------------- | ------------------ | -------------------------- |
| **Normal** | ✅ Valid | ✅ Valid | 200 OK | Access granted |
| **User signs out** | 🗑️ Deleted | 🗑️ Deleted | 401 | Redirected to login |
| **Admin revokes session** | ✅ Valid | 🗑️ Deleted | 401 | Logged out on next request |
| **JWT expired, session valid** | ❌ Expired | ✅ Valid | Auto-refresh → 200 | Transparent |
| **JWT expired, session expired** | ❌ Expired | ❌ Expired | 401 | Redirected to login |
| **JWT valid, session expired** | ✅ Valid | ❌ Expired | 401 | Logged out on next request |
| **User banned** | ✅ Valid | ✅ Valid (banned flag) | 403 | "Account banned" |
Key insight: **JWT validity doesn't guarantee access.** The session lookup (Step 2) is the source of truth.
Session Lifecycle [#session-lifecycle]
Sign Out [#sign-out]
When a user signs out, both the JWT cookie and session are deleted:
```
POST /api/auth/sign-out
│
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ 1. Delete session from Convex DB │
│ 2. Delete JWT cookie (Set-Cookie: better-auth.jwt=; Max-Age=0) │
│ 3. Client: isAuthenticated = false, unsubscribe WebSocket queries │
└─────────────────────────────────────────────────────────────────────────┘
```
Admin Revokes Session [#admin-revokes-session]
When an admin revokes a session, only the database record is deleted. The user's JWT cookie remains, but becomes useless:
```
Admin: POST /api/auth/admin/revoke-user-session
│
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ Session deleted from DB (JWT cookie still exists in user's browser) │
└─────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ User's next request: │
│ Step 1: JWT decoded successfully ✅ │
│ Step 2: Session lookup → null ❌ │
│ Result: 401 UNAUTHORIZED │
└─────────────────────────────────────────────────────────────────────────┘
```
}>
**Security note:** Revocation takes effect on the user's **next request**. For sensitive operations requiring immediate invalidation, use Better Auth's `sensitiveSessionMiddleware`.
JWT Expiration & Auto-Refresh [#jwt-expiration--auto-refresh]
When a JWT expires (default: 15 min), the client automatically fetches a fresh one - if the session is still valid:
```
Client: JWT expired (exp < now)
│
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ GET /api/auth/convex/token (automatic via ConvexAuthProvider) │
└─────────────────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ Server checks session in DB: │
│ • Session valid → Issue new JWT, set cookie │
│ • Session invalid → 401, redirect to login │
└─────────────────────────────────────────────────────────────────────────┘
```
The client uses a **60-second leeway** when checking expiration to prevent edge cases where the token expires mid-request.
Request Flows [#request-flows]
Both SSR and client requests follow the same two-step validation. The difference is transport.
**SSR (HTTP):** Read JWT from cookie (cached by default) → HTTP to Convex → validate JWT → session lookup → query executes → response cached in TanStack Query → client hydrates with prefetched data.
**Client (WebSocket):** Establish persistent connection → auth handshake (blocking) → validate JWT → session lookup → identity cached → all pending queries unblock.
}>
**Key difference from REST:** Convex WebSocket queries are **blocked until the auth handshake completes**. With Static JWKS, the handshake is instant. With Dynamic JWKS, it adds 100-400ms blocking time on page load.
Next Steps [#next-steps]
# Server (/docs/auth/server)
import { InfoIcon } from "lucide-react"
In this guide, you'll install Better Auth through kitcn's CLI-first
flow. The CLI owns the auth scaffold, schema blocks, HTTP route wiring, and the
first local Convex auth bootstrap, so you can start from a working baseline and
only drop to manual wiring when you actually need it.
Start with the CLI [#start-with-the-cli]
If kitcn is not bootstrapped yet, start there first:
Use `npx kitcn@latest init --yes` instead when you are adopting the current
supported app before kitcn is installed locally.
Then install auth:
```bash
npx kitcn add auth --yes
```
On local Convex, that command also finishes the live bootstrap pass for auth:
generated runtime, `BETTER_AUTH_SECRET`, and `JWKS`.
What add auth owns [#what-add-auth-owns]
The default kitcn auth path writes and patches the auth surface for you:
* `/auth.config.ts`
* `/auth.ts`
* auth-owned schema blocks in `/schema.ts`
* auth HTTP wiring in `/http.ts`
* auth-aware client wiring
* the scaffolded auth page and Next auth route when the app shell supports them
`functionsDir` comes from `convex.json.functions`. Scaffolded kitcn
apps use `convex/functions`.
Auth Config [#auth-config]
The scaffolded auth config keeps the first local bootstrap healthy. It uses the
static JWKS when present and falls back to the provider helper before that first
JWKS sync exists.
```ts title="convex/functions/auth.config.ts" showLineNumbers {3-9}
import { getAuthConfigProvider } from 'kitcn/auth/config';
import type { AuthConfig } from 'convex/server';
import { getEnv } from '../lib/get-env';
export default {
providers: [
getEnv().JWKS
? getAuthConfigProvider({ jwks: getEnv().JWKS })
: getAuthConfigProvider(),
],
} satisfies AuthConfig;
```
Auth Contract [#auth-contract]
`auth.ts` is the server contract. It defines the Better Auth options, registers
the Convex adapter, and imports `defineAuth` from the generated runtime:
```ts title="convex/functions/auth.ts" showLineNumbers {1-4,8-13}
import { convex } from 'kitcn/auth';
import { getEnv } from '../lib/get-env';
import authConfig from './auth.config';
import { defineAuth } from './generated/auth';
export default defineAuth(() => ({
emailAndPassword: {
enabled: true,
},
baseURL: getEnv().SITE_URL,
plugins: [
convex({
authConfig,
jwks: getEnv().JWKS,
}),
],
session: {
expiresIn: 60 * 60 * 24 * 30,
updateAge: 60 * 60 * 24 * 15,
},
telemetry: { enabled: false },
trustedOrigins: [getEnv().SITE_URL],
}));
```
This is the file you edit when you add Better Auth plugins or change auth
behavior.
Refresh Auth-Owned Schema [#refresh-auth-owned-schema]
When plugin changes in `auth.ts` affect auth tables, refresh only the auth-owned
schema blocks:
```bash
npx kitcn add auth --schema --yes
```
The default kitcn path patches auth-owned table blocks directly into
`/schema.ts` and refreshes ownership in
`/plugins.lock.json`.
If you want to own the auth tables by hand instead, use the manual backend
reference in [Server Setup](/docs/server/setup). That is the escape hatch, not
the default path.
Raw Convex Path [#raw-convex-path]
Use the raw Convex preset only when the app stays on the plain Convex auth
surface:
```bash
npx kitcn add auth --preset convex --yes
```
That path writes `authSchema.ts`, `auth.config.ts`, `auth.ts`, and raw Convex
HTTP wiring. It assumes the raw Convex app is already initialized. When auth
tables change, rerun the same preset command. It does not support `--schema`.
HTTP Routes [#http-routes]
* Without Hono: use Convex `httpRouter` + `registerRoutes` (no Hono required).
* With Hono or custom middleware chains: use Hono + `authMiddleware`.
```ts title="convex/functions/http.ts" showLineNumbers {1-6,10-19,22}
import { authMiddleware } from 'kitcn/auth/http';
import { createHttpRouter } from 'kitcn/server';
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { getEnv } from '../lib/get-env';
import { router } from '../lib/crpc';
import { getAuth } from './generated/auth';
const app = new Hono();
// CORS for API routes
app.use(
'/api/*',
cors({
origin: getEnv().SITE_URL,
allowHeaders: ['Content-Type', 'Authorization', 'Better-Auth-Cookie'],
exposeHeaders: ['Set-Better-Auth-Cookie'],
credentials: true,
})
);
// Better Auth middleware
app.use(authMiddleware(getAuth));
export const httpRouter = router({
// Add your routers here
});
export default createHttpRouter(app, httpRouter);
```
```ts title="convex/functions/http.ts" showLineNumbers {1-4,6-13}
import { registerRoutes } from 'kitcn/auth/http';
import { httpRouter } from 'convex/server';
import { getAuth } from './generated/auth';
const http = httpRouter();
registerRoutes(http, getAuth, {
cors: {
allowedOrigins: [process.env.SITE_URL!],
},
});
export default http;
```
```ts title="convex/functions/http.ts" showLineNumbers {1-5,9-18,21}
import { authMiddleware } from 'kitcn/auth/http';
import { HttpRouterWithHono } from 'kitcn/server';
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { getEnv } from '../lib/get-env';
import { getAuth } from './generated/auth';
const app = new Hono();
// CORS for API routes
app.use(
'/api/*',
cors({
origin: getEnv().SITE_URL,
allowHeaders: ['Content-Type', 'Authorization', 'Better-Auth-Cookie'],
exposeHeaders: ['Set-Better-Auth-Cookie'],
credentials: true,
}),
);
// Better Auth middleware
app.use(authMiddleware(getAuth));
export default new HttpRouterWithHono(app);
```
}>
See [HTTP Router](/docs/server/http) for route examples, webhooks, and streaming.
Sync Environment Variables [#sync-environment-variables]
Base setup already creates `convex/.env`. Keep `SITE_URL` and any provider
credentials current there. For the normal local path, `SITE_URL` should stay on
`http://localhost:3000`.
Typical local values:
```bash title="convex/.env" showLineNumbers
SITE_URL=http://localhost:3000
GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secret
```
For local auth env sync, use the lane that matches your backend.
Convex backend [#convex-backend]
For local Convex, no extra bootstrap command is needed after that.
`kitcn init --yes`, `kitcn dev`, and
`kitcn add auth --yes` already drive the local auth bootstrap when they
own the flow.
To rotate keys later (this invalidates all tokens):
```bash
npx kitcn env push --rotate
```
Concave backend [#concave-backend]
Concave has no `kitcn env push` wrapper. Export a fresh JWKS payload from the
running backend, then update your env manually:
```bash
npx kitcn --backend concave auth jwks --rotate --url http://localhost:3210
```
Use `--url`, `--port`, or `--component` to target the Concave runtime you
actually want.
See [CLI Backend](/docs/cli/backend#env) for the full backend split.
Framework Integration [#framework-integration]
You've set up the Convex backend. Now let's connect your frontend:
Key Concepts [#key-concepts]
Now that you have auth working, let's understand a few important concepts.
Context-Aware Adapter Selection [#context-aware-adapter-selection]
Queries and mutations read the session row directly. Reach for `getAuth(ctx)` in the modules that
call `auth.api.*`:
```ts showLineNumbers {2,7}
export const someQuery = publicQuery.query(async ({ ctx }) => {
const session = await getSession(ctx);
});
export const someAction = publicAction.action(async ({ ctx }) => {
const auth = getAuth(ctx);
});
```
}>
**Bundle:** Convex has no dynamic `import()`, so every module that names `getAuth` carries your
whole Better Auth definition and all its plugins in its evaluation closure. Keep it out of
`convex/lib/crpc.ts`, which every procedure module imports. Authenticated actions build from
`convex/lib/crpc-action.ts`, the one builder module that imports it.
Generated `getAuth(ctx)` uses the same context-aware adapter selection internally:
| Adapter | Context | Behavior |
| ------------- | ---------------------------- | ----------------------------------------------------- |
| `adapter` | Queries/mutations (`ctx.db`) | Direct DB access, no `runQuery`/`runMutation` wrapper |
| `httpAdapter` | Actions/HTTP contexts | Uses `ctx.run*` APIs |
}>
**Performance:** In queries/mutations, the direct DB adapter is selected automatically, avoiding the extra `ctx.runQuery`/`ctx.runMutation` hop.
Triggers [#triggers]
The recommended approach is schema-level [ORM Triggers](/docs/orm/schema/triggers) via `defineTriggers` in `schema.ts`. These run for **all writes** to auth tables (ORM mutations, auth API, direct `ctx.db`).
```ts title="convex/functions/schema.ts" showLineNumbers {1-2,4-20}
import { defineTriggers } from 'kitcn/orm';
import { relations } from './relations';
const triggers = defineTriggers(relations, {
user: {
create: {
before: async (data) => {
return { data: { ...data, role: 'user' } };
},
after: async (doc, ctx) => {
// e.g. create personal organization, send welcome email
},
},
},
session: {
create: {
after: async (session, ctx) => {
// e.g. set activeOrganizationId from last active
},
},
},
});
```
See [Triggers](/docs/orm/schema/triggers) for the full hook signatures, `change` handler, cancellation, and `withoutTriggers`.
If you're not using the ORM, define triggers inline in `auth.ts`. These run only for Better Auth API operations:
```ts title="convex/functions/auth.ts" showLineNumbers {1-11}
import { getEnv } from '../lib/get-env';
import { defineAuth } from './generated/auth';
export default defineAuth(() => ({
baseURL: getEnv().SITE_URL,
triggers: {
user: {
create: {
before: async (data, triggerCtx) => {
return { data: { ...data, role: 'user' } };
},
after: async (doc, triggerCtx) => {},
},
},
},
}));
```
See [migrating from Better Auth component](/docs/migrations/auth) for this pattern.
Environment Variables [#environment-variables]
Here's a quick reference for all the environment variables:
| Variable | Description |
| -------------------- | -------------------------------------------------------- |
| `SITE_URL` | Your app URL (e.g., `http://localhost:3000`) |
| `JWKS` | Auto-generated during local auth bootstrap or `env push` |
| `BETTER_AUTH_SECRET` | Auto-generated during local auth bootstrap or `env push` |
Social providers (optional):
| Variable | Description |
| ---------------------- | -------------------------- |
| `GOOGLE_CLIENT_ID` | Google OAuth client ID |
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret |
The auto-generated `JWKS` and `BETTER_AUTH_SECRET` behavior above describes the
Convex backend. On Concave, `kitcn env push` does not exist, so treat env sync as
manual and use `kitcn auth jwks` only for `JWKS` export.
Production Deployment [#production-deployment]
Production auth sync splits by backend too.
Convex backend [#convex-backend-1]
Your first Convex production deployment will fail authentication because `JWKS`
isn't set yet. That's expected.
**1. Deploy your app to production**
```bash
npx convex deploy --prod
```
**2. Sync auth env**
Run from your local machine with Convex CLI authenticated:
```bash
npx kitcn env push --prod
```
This pushes `convex/.env`, generates `BETTER_AUTH_SECRET` when missing, fetches
`JWKS`, and writes the full batch to the production deployment.
You do not need to copy the output into env yourself. `kitcn env push --prod`
updates the target deployment env for you.
**3. Verify authentication works**
Test a protected endpoint or sign in through your app. Authentication should
now succeed.
}>
**Troubleshooting:** If you see "Invalid token signature" errors after deployment, re-run `kitcn env push --prod`.
Concave backend [#concave-backend-1]
Concave has no `kitcn env push --prod` flow. Export a static JWKS payload from
the target backend, then set the printed `JWKS=...` line in your deployment
platform manually:
```bash
npx kitcn --backend concave auth jwks --url https://your-concave-backend.example.com
```
Use the same backend target your app will actually hit. The command supports
`--url`, `--port`, and `--component`.
Yes, you need to save that printed `JWKS=...` value into env yourself. `kitcn
auth jwks` only prints the payload; it does not update deployment env for you.
Key Rotation [#key-rotation]
Need to rotate signing keys? Maybe for security compliance or after a suspected
compromise:
Convex backend [#convex-backend-2]
```bash
npx kitcn env push --rotate --prod
```
Concave backend [#concave-backend-2]
```bash
npx kitcn --backend concave auth jwks --rotate --url https://your-concave-backend.example.com
```
After that, update the deployed `JWKS` env manually with the printed value.
}>
**Warning:** Key rotation invalidates all existing tokens. All users will be logged out and must re-authenticate. Plan rotations during low-traffic periods.
**Rotation checklist:**
* Schedule during low-traffic window
* Notify users about required re-login (optional)
* Run `rotateKeys` command
* Verify authentication works with new keys
* Monitor for authentication errors
API Reference [#api-reference]
Convex Plugin [#convex-plugin]
The `convex` plugin handles JWT generation, cookie handling, and JWKS endpoints. Here's what you can configure:
| Option | Required | Description |
| ------------ | -------- | ---------------------------------------------------- |
| `authConfig` | Yes | Your Convex auth config from `convex/auth.config.ts` |
| `jwt` | No | Customize token expiration and payload |
| `jwks` | No | Static JWKS string for faster validation |
| `options` | No | Pass custom `basePath` if using non-default routes |
The simplest setup just needs your auth config:
```ts showLineNumbers
convex({
authConfig,
})
```
Customizing JWT Tokens [#customizing-jwt-tokens]
By default, JWTs expire after 15 minutes. The default `definePayload` includes all user fields except `id` and `image`, plus the session ID and issued-at timestamp:
```ts showLineNumbers
definePayload: ({ user, session }) => ({
...omit(user, ["id", "image"]),
sessionId: session.id,
iat: Math.floor(new Date().getTime() / 1000),
});
```
Here's how to customize the expiration and payload - for example, extending to 4 hours and including only specific fields:
```ts showLineNumbers {3-11}
convex({
authConfig,
jwt: {
expirationSeconds: 60 * 60 * 4, // 4 hours
definePayload: ({ user, session }) => ({
name: user.name,
email: user.email,
role: user.role,
sessionId: session.id,
}),
},
})
```
}>
The `sessionId` and `iat` (issued-at) fields are always added automatically, even if you don't include them in your custom payload.
Static JWKS [#static-jwks]
For best performance, pass your JWKS as an environment variable. This eliminates database lookups during token validation:
```ts showLineNumbers {3}
convex({
authConfig,
jwks: process.env.JWKS,
})
```
Use the auth/JWKS helpers that match your backend.
Convex backend [#convex-backend-3]
```bash
# Sync local deployment env, including JWKS
npx kitcn env push
# Sync production deployment env, including JWKS
npx kitcn env push --prod
# Rotate auth keys, then push fresh JWKS
npx kitcn env push --rotate
```
Concave backend [#concave-backend-3]
```bash
# Print a manual JWKS env payload
npx kitcn --backend concave auth jwks --url http://localhost:3210
# Rotate auth keys, then print a fresh manual JWKS payload
npx kitcn --backend concave auth jwks --rotate --url http://localhost:3210
```
Use `kitcn env push` on backend `convex`.
It writes the target deployment env for you, so there is no separate manual
copy step.
Use `kitcn auth jwks` on backend `concave`, where `kitcn env` has no upstream
env command to wrap.
It only prints `JWKS=...`, so you must save that value into env yourself.
See [CLI env commands](/docs/cli/backend#env) and
[CLI auth helpers](/docs/cli/backend#auth) for the full target/repair flow.
See [Authentication Flow](/docs/auth#authentication-flow) to understand why this matters.
Custom Base Path [#custom-base-path]
If your Better Auth uses a non-default `basePath`, pass the same value here so the JWKS endpoint is configured correctly:
```ts showLineNumbers {3-5}
convex({
authConfig,
options: {
basePath: "/custom/auth/path",
},
})
```
getAuthUserIdentity [#getauthuseridentity]
Returns the full user identity:
```ts showLineNumbers {1,3,5-8}
import { getAuthUserIdentity } from 'kitcn/auth';
const identity = await getAuthUserIdentity(ctx);
if (identity) {
identity.userId; // Id<'user'>
identity.sessionId; // Id<'session'>
identity.subject; // string (user ID as string)
}
```
getAuthUserId [#getauthuserid]
If you just need the user ID:
```ts showLineNumbers {1,3,5-7,10}
import { getAuthUserId } from 'kitcn/auth';
const userId = await getAuthUserId(ctx);
if (!userId) {
throw new CRPCError({ code: 'UNAUTHORIZED' });
}
// userId is Id<'user'>
const user = await ctx.orm.query.user.findFirst({ where: { id: userId } });
```
getSession [#getsession]
Returns the full session document:
```ts showLineNumbers {1,3,5-10}
import { getSession } from 'kitcn/auth';
const session = await getSession(ctx);
if (session) {
session.id; // Id<'session'>
session.userId; // Id<'user'>
session.activeOrganizationId; // Id<'organization'> | null
session.expiresAt; // number
}
```
getHeaders [#getheaders]
Need to call an external API with the current session? This builds the headers:
```ts showLineNumbers {1,3-4,7-9}
import { getHeaders } from 'kitcn/auth';
const headers = await getHeaders(ctx);
// Headers { authorization: 'Bearer ...', x-forwarded-for: '...' }
// Use with fetch
const response = await fetch('https://api.example.com', {
headers,
});
```
Next Steps [#next-steps]
Done! You now have authentication set up. Here's where to go next:
# Backend (/docs/cli/backend)
dev [#dev]
```bash showLineNumbers
npx kitcn dev
```
Options:
| Flag | Description |
| ---------------------------------------------------------- | ------------------------------------------------------ |
| `--api ` | Output directory for api.ts (default: `convex/shared`) |
| `--backend ` | Backend CLI to drive |
| `--bootstrap` | Run one-shot local Convex bootstrap and exit |
| `--config ` | Path to kitcn config file (default: `./kitcn.json`) |
| `--backfill=auto\|on\|off` | Dev aggregate backfill mode toggle (default: `auto`) |
| `--backfill-wait` / `--no-backfill-wait` | Wait for all target indexes to become `READY` |
| `--backfill-batch-size ` | Batch size for aggregate backfill kickoff |
| `--migrations=auto\|on\|off` | Dev migration mode toggle (default: `auto`) |
| `--migrations-wait` / `--no-migrations-wait` | Wait for migration run completion |
| `--migrations-allow-drift` / `--no-migrations-allow-drift` | Override drift policy in dev |
Wraps the selected backend `dev` command, generates runtime files on startup, watches for changes, and passes all other flags through.
On backend `convex`, `kitcn dev` runs `convex init` before codegen,
watchers, and the main `convex dev` process.
Set `dev.preRun` in `kitcn.json` to run one Convex
setup function inside the main dev loop. It is Convex-only; backend `concave`
has no equivalent `--run` flow.
`dev.preRun` maps to Convex's native `convex dev --run ` flow with
the same deployment-target args as the main dev command.
Local backend state depends on the selected backend:
* Convex: `.convex/` at project root
* Concave Bun/Node: `.concave/local/`
`kitcn dev` always runs full cRPC generation by default. Use `kitcn codegen --scope ...` for one-off scoped generation.
When aggregate backfill is enabled, `kitcn dev` kicks off `aggregateBackfill` and waits for `READY` by default (in background).
When `convex/functions/schema.ts` changes, `kitcn dev` re-runs resume backfill automatically only if aggregate index signatures changed.
```bash showLineNumbers
# One-shot local Convex bootstrap
npx kitcn dev --bootstrap
```
verify [#verify]
```bash showLineNumbers
npx kitcn verify
```
Runs a one-shot local Convex runtime proof through the kitcn dev path.
Stop any long-running local backend process first.
Use it when you want a CI-safe answer to one question: does local kitcn
dev boot cleanly right now?
`kitcn verify`:
* runs the runtime path through `dev --once`
* reuses the current local Convex deployment when one is already configured
* lets Convex use its non-interactive anonymous local setup when no local
deployment is configured
codegen [#codegen]
Generate `api.ts` once against the selected backend connection:
```bash showLineNumbers
npx kitcn codegen
```
Options:
| Flag | Description |
| ----------------------------- | --------------------------------------------------- |
| `--api ` | Output directory (default: `convex/shared`) |
| `--backend ` | Backend CLI to drive |
| `--scope ` | Generation scope (default: `all`) |
| `--config ` | Path to kitcn config file (default: `./kitcn.json`) |
| `--debug` | Show detailed output |
| `--silent` | Suppress all output |
See [Scope Modes](#scope-modes) for the full output manifest per scope.
`/generated/server.ts` is codegen-owned and is rewritten from your
schema before codegen reads project modules. Never edit it by hand.
Plugin note:
* `kitcn codegen` discovers plugins from schema extensions and local plugin scaffold registration.
* Plugin discovery is used for typing/runtime context only.
* `codegen` does not generate plugin runtime modules. Plugin runtime files are scaffold-owned.
env [#env]
Manage environment variables:
Convex backend [#convex-backend]
```bash showLineNumbers {2,5,8,11,14,17,20}
# Push all vars from convex/.env
npx kitcn env push
# Force overwrite existing values
npx kitcn env push --force
# Production deployment
npx kitcn env push --prod
# Rotate auth keys, then push fresh JWKS
npx kitcn env push --rotate
# Pull remote values to stdout
npx kitcn env pull
# Write remote values to a file
npx kitcn env pull --out convex/.env.remote
```
Push options:
| Flag | Description |
| -------------------- | ------------------------------------------------------- |
| `--rotate` | Rotate auth keys before fetching fresh JWKS |
| `--force` | Overwrite existing values (default: skip if up to date) |
| `--from-file ` | Push values from a file instead of `convex/.env` |
Pull options:
| Flag | Description |
| -------------- | ----------------------------------------------- |
| `--out ` | Write pulled values to a file instead of stdout |
Target options:
| Flag | Description |
| -------------------------- | ------------------------------------------------ |
| `--prod` | Target the production deployment |
| `--deployment-name ` | Target a named deployment |
| `--preview-name ` | Target a named preview deployment |
| `--env-file ` | Use an env file to resolve the target deployment |
`kitcn env push` reads `convex/.env` by default, filters Convex-managed
variables, then pushes one batch update through `convex env set --from-file`.
If auth scaffold is installed, it also ensures `BETTER_AUTH_SECRET` exists
locally, fetches `JWKS` with `convex run generated/auth:getLatestJwks`, and
includes both in the push payload. With `--rotate`, it runs
`convex run generated/auth:rotateKeys` first, then pushes the fresh `JWKS`.
On backend `convex`, `kitcn dev` handles the local auth bootstrap flow and
watches `convex/.env` for later local edits. `kitcn dev --bootstrap` is the
one-shot local setup version of that flow. `kitcn verify` is the one-shot local
runtime proof. `kitcn add auth --yes` reuses the same local bootstrap path when
it needs generated auth runtime and JWKS. Use `kitcn env push` for `--prod`,
`--rotate`, or explicit repair against an already active deployment.
`kitcn env push` writes the target deployment env for you. You do not need to
copy command output into env by hand.
If `--from-file` is omitted and stdin is piped, `kitcn env push` reads the
piped payload instead of `convex/.env`.
`kitcn env pull` wraps `convex env list` and preserves the exact payload
format, so you can print it or write it to disk unchanged.
All env commands forward Convex deployment-target args such as `--prod`,
`--deployment-name`, `--preview-name`, and `--env-file`.
Other env commands stay aligned with Convex:
```bash showLineNumbers
npx kitcn env list
npx kitcn env get
npx kitcn env set
npx kitcn env remove
npx kitcn env default list --type dev
npx kitcn env default set SITE_URL https://app.example.com --type prod
```
`kitcn env set` supports interactive values, `--from-file`, and stdin input.
`kitcn env pull` gives you the same robust export format, so
`env pull --out ` and `env set --from-file ` round-trip cleanly.
`kitcn env default` forwards to `convex env default` for project defaults used
by new dev, preview, and production deployments.
Unknown commands on backend `convex` pass straight through to the Convex CLI,
so `kitcn insights` opens the same insights surface as `convex insights`.
Convex passthrough [#convex-passthrough]
Unknown Convex commands stay available through `kitcn`:
```bash showLineNumbers
# Run an ad-hoc readonly query without adding a function file
npx kitcn run --inline-query 'await ctx.db.query("messages").take(5)'
# Create and select a shared dev deployment for the current branch
npx kitcn deployment create "$(git branch --show-current)" --type dev --select
# Create a long-lived staging deployment
npx kitcn deployment create staging --type prod
```
Use `kitcn env default` before creating new deployments when every dev,
preview, or production deployment should start with the same env values.
Convex runtime APIs such as `ctx.meta.getFunctionMetadata()` and
`ctx.meta.getTransactionMetrics()` are used inside Convex functions. `kitcn`
does not wrap them.
Concave backend [#concave-backend]
For manual JWKS export on Concave:
```bash showLineNumbers
npx kitcn --backend concave auth jwks --url http://localhost:3210
npx kitcn --backend concave auth jwks --rotate --url http://localhost:3210
```
`kitcn auth jwks` only prints `JWKS=...`. You must save that value into env
yourself.
auth [#auth]
Manual auth runtime helpers:
Convex backend [#convex-backend-1]
```bash showLineNumbers
# Print a paste-ready JWKS env line
npx kitcn auth jwks
# Rotate auth keys, then print a fresh JWKS env line
npx kitcn auth jwks --rotate
# Machine-readable output
npx kitcn auth jwks --json
```
Use this when you want a manual static `JWKS` payload without going through
`kitcn env push`, or when you want machine-readable output.
This command prints the payload. It does not update env by itself.
Convex target flags are forwarded to the backend `run` command:
* `--prod`
* `--deployment-name `
* `--preview-name `
* `--env-file `
* `--component `
Concave backend [#concave-backend-1]
```bash showLineNumbers
# Print a paste-ready JWKS env line from the local default runtime
npx kitcn --backend concave auth jwks --url http://localhost:3210
# Rotate auth keys, then print a fresh JWKS env line
npx kitcn --backend concave auth jwks --rotate --url http://localhost:3210
# Machine-readable output
npx kitcn --backend concave auth jwks --json --url http://localhost:3210
```
On backend `concave`, this is the manual JWKS sync path.
This command only prints `JWKS=...`. Save that value into your deployment env
yourself.
Concave target flags are forwarded to the backend `run` command:
* `--url `
* `--port `
* `--component `
Shared options:
| Flag | Description |
| ---------- | ---------------------------------------------------------------- |
| `--rotate` | Rotate auth keys before fetching JWKS |
| `--json` | Emit `{ jwks, envLine, ... }` JSON instead of a shell-ready line |
deploy [#deploy]
```bash showLineNumbers
npx kitcn deploy --prod
npx kitcn deploy --prod --message "Release $(git rev-parse --short HEAD)"
npx kitcn deploy --preview-name "$(git branch --show-current)"
```
`kitcn deploy` runs:
1. selected backend `deploy ...`
2. post-deploy `generated/server:migrationRun` (`direction: "up"`)
3. status polling (`generated/server:migrationStatus`) until completion (default behavior)
4. post-deploy `generated/aggregate:aggregateBackfill`
5. aggregate status polling (`generated/aggregate:aggregateBackfillStatus`) until `READY` (default behavior)
Options:
| Flag | Description |
| ---------------------------------------------------------- | ---------------------------------------------------- |
| `--backfill=auto\|on\|off` | Enable/disable post-deploy aggregate backfill |
| `--backfill-wait` / `--no-backfill-wait` | Wait for all target indexes to become `READY` |
| `--backfill-batch-size ` | Batch size for backfill jobs |
| `--backfill-poll-ms ` | Poll interval while waiting |
| `--backfill-timeout-ms ` | Max wait duration before timeout |
| `--backfill-strict` / `--no-backfill-strict` | Fail deploy on timeout/backfill error |
| `--migrations=auto\|on\|off` | Enable/disable post-deploy `migrate up` |
| `--migrations-wait` / `--no-migrations-wait` | Wait for migration completion |
| `--migrations-batch-size ` | Batch size for migration workers |
| `--migrations-poll-ms ` | Poll interval while waiting for migration completion |
| `--migrations-timeout-ms ` | Max migration wait duration before timeout |
| `--migrations-strict` / `--no-migrations-strict` | Fail deploy on migration timeout/error |
| `--migrations-allow-drift` / `--no-migrations-allow-drift` | Override drift blocking policy |
| `--message ` | Forward deployment audit-log message to Convex |
| `--preview-name ` | Deploy to a reusable preview deployment |
Already-`READY` indexes are skipped (noop). See [Resume Compatibility](#resume-compatibility) for full behavior rules.
aggregate rebuild [#aggregate-rebuild]
```bash showLineNumbers
npx kitcn aggregate rebuild --prod
```
Runs `generated/aggregate:aggregateBackfill` in rebuild mode (clear + recompute) for the selected deployment, then waits for `READY` by default.
aggregate backfill [#aggregate-backfill]
```bash showLineNumbers
npx kitcn aggregate backfill --prod
```
Runs `generated/aggregate:aggregateBackfill` in resume mode (no clear/rebuild) for the selected deployment, then waits for `READY` by default.
aggregate prune [#aggregate-prune]
```bash showLineNumbers
npx kitcn aggregate prune --prod
```
Runs `generated/aggregate:aggregateBackfill` in prune mode to delete storage/state for aggregate indexes no longer declared in schema. Large indexes are cleared in scheduled batches; the command reports how many are still draining.
migrate create [#migrate-create]
```bash showLineNumbers
npx kitcn migrate create backfill_user_status
```
Creates a timestamped migration file under `convex/functions/migrations/` and regenerates `convex/functions/migrations/manifest.ts`.
migrate up [#migrate-up]
```bash showLineNumbers
npx kitcn migrate up --prod
```
Runs `generated/server:migrationRun` with `direction: "up"` and waits by default.
migrate down [#migrate-down]
```bash showLineNumbers
# Roll back the latest migration
npx kitcn migrate down --steps 1 --prod
# Roll back everything after a target migration id
npx kitcn migrate down --to 20260227_000000_backfill_user_ban_reason --prod
```
Runs `generated/server:migrationRun` with `direction: "down"` in reverse applied order.
migrate status [#migrate-status]
```bash showLineNumbers
npx kitcn migrate status --prod
```
Reads migration run/state internals from the `generated/server:migrationStatus`
query.
migrate cancel [#migrate-cancel]
```bash showLineNumbers
npx kitcn migrate cancel --prod
```
Cancels the active migration run (`generated/server:migrationCancel`).
reset [#reset]
Wipe all table data in one step. Useful for dev resets and seeding workflows.
```bash showLineNumbers
npx kitcn reset --yes
```
The `--yes` flag is required — reset is destructive. Under the hood it calls `generated/server:reset` (which uses `ctx.orm.withoutTriggers` to clear every table, including internal aggregate and migration state tables).
You can run custom functions before and after the reset:
```bash showLineNumbers {2,5}
# Seed data after reset
npx kitcn reset --yes --after generated/seed:seedAll
# Snapshot before, seed after
npx kitcn reset --yes --before generated/seed:snapshot --after generated/seed:seedAll
```
Options:
| Flag | Description |
| --------------- | --------------------------------------- |
| `--yes` | **Required.** Confirm destructive reset |
| `--before ` | Run a Convex function before reset |
| `--after ` | Run a Convex function after reset |
analyze [#analyze]
Analyze Convex runtime bundle size and dependency hotspots:
```bash showLineNumbers {2,5,8}
# Function-oriented hotspot ranking (default)
npx kitcn analyze
# Deploy-accurate bundle view
npx kitcn analyze --deploy
# Deep drilldown for one entry
npx kitcn analyze '^convex/functions/auth\\.ts$' --details
```
Options:
| Flag | Description |
| --------------------- | ------------------------------------------------------------------------- |
| `--deploy` | Deploy-accurate isolate bundle analysis |
| `--details` | Show expanded per-entry package graph details |
| `--input` | Include top internal inputs in expanded details |
| `--interactive`, `-i` | Enable interactive hotspot UI (default: off, TTY only) |
| `--all`, `-a` | Include Convex-ignored entries (multi-dot files and `generated/` modules) |
| `--show-small` | Include tiny dependencies (hidden by default) |
| `--top-inputs ` | Rows for input tables (default: `12`) |
| `--top-packages ` | Rows for package tables (default: `12`) |
| `--warn-mb ` | WARN threshold in MB (default: `6`) |
| `--danger-mb ` | DANGER threshold in MB (default: `8`) |
| `--fail-mb ` | Exit with code `1` if largest output meets threshold |
| `--width ` | Force terminal output width |
Hotspot ranking columns:
| Column | Meaning |
| ------- | -------------------------------------------------------------- |
| `OutMB` | Bundled size of the isolate this entry deploys into |
| `DepMB` | Source size of the dependency files that land in that isolate |
| `LocMB` | Source size of your own Convex files that land in that isolate |
| `Files` | Number of files that contribute bytes to that isolate |
Every column describes the same set: what the entry actually bundles. A file that
is imported but fully tree-shaken away is counted in none of them.
See [Mode Behavior](#mode-behavior) for the full 12-item behavior reference.
Output [#output]
The CLI generates `convex/shared/api.ts`. Do not create this file manually — it is fully generated by `kitcn dev` / `kitcn codegen`.
The CLI extracts metadata from procedure exports automatically. See [Metadata Extraction](#metadata-extraction) for supported patterns and base procedure mapping.
Configuration [#configuration]
`kitcn` supports a project config at `./kitcn.json` (or pass `--config `).
The CLI reads from `convex.json`:
```json title="convex.json" showLineNumbers {2}
{
"functions": "convex"
}
```
The `functions` field specifies the Convex functions directory (default: `convex`).
Path Aliases [#path-aliases]
Add a path alias to import `@convex/api`:
```json title="tsconfig.json" showLineNumbers {4}
{
"compilerOptions": {
"paths": {
"@convex/*": ["./convex/shared/*"]
}
}
}
```
Then import:
```ts showLineNumbers
import { api } from '@convex/api';
```
Next Steps [#next-steps]
***
API Reference [#api-reference]
Scope Modes [#scope-modes]
Scope modes apply to `kitcn codegen`.
| Scope | Generated outputs |
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `all` (default) | `convex/shared/api.ts`, `/generated/server.ts`, `/generated/auth.ts`, `/generated/procedure-names.gen.ts`, runtime files |
| `auth` | `/generated/server.ts`, `/generated/auth.ts`, `/generated/auth.runtime.ts` (when `/auth.ts` has a default export) |
| `orm` | `/generated/server.ts`, runtime files for ORM internals |
In scoped modes, stale artifacts outside the current scope are removed automatically (for example `convex/shared/api.ts` and cRPC runtime modules in `auth`/`orm` mode).
`/generated/procedure-names.gen.ts` is the only output that depends on scanning your procedures, which is why it is written in `all` scope alone. `auth` and `orm` scope leave it untouched so the procedure names recorded by the last full run survive.
Resume Compatibility [#resume-compatibility]
* key shape changes (`aggregateIndex(...).on(...)` fields / `.all()` key shape) require rebuild; deploy exits in strict mode and instructs `kitcn aggregate rebuild`
* metric additions (`.count/.sum/.avg/.min/.max`) are backfilled automatically
* metric removals are metadata-only updates (no clear/rebuild)
* removed aggregate indexes are pruned automatically (state + bucket/member/extrema rows)
Mode Behavior [#mode-behavior]
1. Default mode (`kitcn analyze`) ranks least optimized Convex function modules by output size and dependency weight. Every selected entry is measured in one bundler pass, and each entry is still sized as its own independently tree-shaken isolate.
2. `--deploy` mirrors deploy shape by bundling the same selected entry set together with shared chunks. Use it when you want deployment totals rather than per-function isolate sizes.
3. Convex-ignored entries with detected handlers are included by default in both modes. Add `--all` to include every ignored entry (including ones without detected handlers).
4. `--details` adds a package graph view (size + top cross-package imports). Add `--input` to include the top internal inputs table.
5. Filter to specific entries by passing a positional regex: `kitcn analyze polar.*` or `kitcn analyze '^convex/functions/polar.*\\.ts$'`.
6. Hotspot output is agent-first: it prints an **Agent queue** with ready-to-run optimization commands.
7. Use `--interactive` to open the keyboard-driven hotspot UI (`--interactive` is hotspot-only and rejected with `--deploy`).
8. Ranking includes only modules with detected handler exports (cRPC `_crpcMeta` scan, plus native Convex handler exports).
9. Interactive mode uses split panes by default (entry list left, detail preview right, key/status bar bottom).
10. Right pane always follows the current selection (no pin mode).
11. On narrow terminals (`<120` columns), interactive mode falls back to stacked layout (list first, details second, key/status last).
12. Interactive controls: `j/k` move, `←/→` pane cycle, `/` filter, `s` sort cycle, `g` all-toggle, `r` refresh, `w` watch toggle, `q` quit, `?` help.
Metadata Extraction [#metadata-extraction]
Supported Patterns [#supported-patterns]
```ts showLineNumbers {2-4,7-9,12-14}
// Base procedure auth is detected
export const list = publicQuery.query(...); // auth: undefined
export const me = authQuery.query(...); // auth: 'required'
export const profile = optionalAuthQuery.query(...); // auth: 'optional'
// Function type is detected
export const get = publicQuery.query(...); // type: 'query'
export const create = publicMutation.mutation(...); // type: 'mutation'
export const sync = publicAction.action(...); // type: 'action'
// .meta() is extracted
export const admin = authQuery
.meta({ role: 'admin' })
.query(...); // { auth: 'required', role: 'admin', type: 'query' }
```
Base Procedure Mapping [#base-procedure-mapping]
| Base Procedure | Auth Type |
| ---------------------- | ------------ |
| `publicQuery` | `undefined` |
| `publicMutation` | `undefined` |
| `publicAction` | `undefined` |
| `optionalAuthQuery` | `'optional'` |
| `optionalAuthMutation` | `'optional'` |
| `authQuery` | `'required'` |
| `authMutation` | `'required'` |
| `authAction` | `'required'` |
Configuration Reference [#configuration-reference]
`kitcn` supports a project config at `./kitcn.json` (or `--config `):
```json title="kitcn.json" showLineNumbers
{
"paths": {
"lib": "convex/lib",
"shared": "convex/shared"
},
"dev": {
"debug": false,
"args": [],
"preRun": "init",
"aggregateBackfill": {
"enabled": "auto",
"wait": true,
"batchSize": 1000,
"pollIntervalMs": 1000,
"timeoutMs": 900000,
"strict": false
},
"migrations": {
"enabled": "auto",
"wait": true,
"batchSize": 256,
"pollIntervalMs": 1000,
"timeoutMs": 900000,
"strict": false,
"allowDrift": true
}
},
"codegen": {
"debug": false,
"scope": "all",
"args": []
},
"deploy": {
"args": [],
"aggregateBackfill": {
"enabled": "auto",
"wait": true,
"batchSize": 1000,
"pollIntervalMs": 1000,
"timeoutMs": 900000,
"strict": true
},
"migrations": {
"enabled": "auto",
"wait": true,
"batchSize": 256,
"pollIntervalMs": 1000,
"timeoutMs": 900000,
"strict": true,
"allowDrift": false
}
}
}
```
Precedence:
1. CLI flags override config values.
2. Config `dev.args` / `codegen.args` / `deploy.args` are prepended to CLI passthrough args.
3. If `codegen.scope` is missing, it defaults to `all`.
4. Plugin lockfile path is fixed at `/plugins.lock.json`.
If no config file is found, defaults are used silently. If `--config` points to a missing file, the CLI exits with an error.
# CLI (/docs/cli)
Overview [#overview]
The CLI has two jobs:
| Guide | Use it for |
| ------------------------------ | --------------------------------------------------------------------------------- |
| [Registry](/docs/cli/registry) | `init`, `create`, `add`, `view`, `info`, and `docs` |
| [Backend](/docs/cli/backend) | `dev`, `codegen`, `env`, `deploy`, `aggregate`, `migrate`, `reset`, and `analyze` |
Installation [#installation]
Backend resolves in this order:
1. `--backend `
2. `backend` in `kitcn.json`
3. default `convex`
Choose a guide [#choose-a-guide]
Command Map [#command-map]
| Area | Commands |
| ---------------- | ----------------------------------- |
| Project setup | `init`, `create` |
| Feature layering | `add`, `view`, `info`, `docs` |
| Backend workflow | `dev`, `codegen`, `env`, `deploy` |
| Data lifecycle | `aggregate *`, `migrate *`, `reset` |
| Diagnostics | `analyze` |
Next Steps [#next-steps]
# CLI Registry (/docs/cli/registry)
Run the CLI first. `kitcn init` is the bootstrap command. It can
scaffold a fresh app when you pass `-t`, or adopt the current supported app in
place when you do not.
Start Here [#start-here]
Use this table when you want the blunt version:
| Goal | Command |
| ------------------------------- | --------------------------- |
| Start a new Next.js app | `kitcn init -t next --yes` |
| Start a new TanStack Start app | `kitcn init -t start --yes` |
| Start a new Vite app | `kitcn init -t vite --yes` |
| Start a new Expo app | `kitcn init -t expo --yes` |
| Adopt the current supported app | `kitcn init --yes` |
| Add a capability | `kitcn add ` |
| Verify local runtime | `kitcn verify` |
| Preview a plugin plan | `kitcn view ` |
| Inspect installed state | `kitcn info` |
| Open docs from the CLI | `kitcn docs ` |
init [#init]
We'll start with `init`, because it owns bootstrap in both directions.
Scaffold a fresh app [#scaffold-a-fresh-app]
Pass `-t next`, `-t start`, `-t vite`, or `-t expo` when you want kitcn to create the app shell
and patch its integration layer in one pass.
For a fresh Next.js app, `init -t next` creates the shadcn shell, then layers
in the kitcn seam:
* `components/providers.tsx`
* `lib/convex/*`
* `.env.local`
* `convex/functions/schema.ts`
* `convex/functions/http.ts`
* `convex/functions/messages.ts`
* `convex/functions/generated/messages.runtime.ts`
* `convex/lib/crpc.ts`
* `convex/lib/get-env.ts`
* `convex/shared/api.ts`
* `kitcn.json`
It also applies a few narrow shell patches where they belong:
* `app/layout.tsx` mounts `Providers`
* `tsconfig.json` gets the `@convex/*` alias
* `package.json` gets the kitcn scripts and baseline dependency patch
For a fresh Vite app, `init -t vite` keeps the flow smaller. It writes the
React client core, the backend baseline, and the shared env/codegen wiring.
That means provider and cRPC setup are ready without pretending Vite is a Next
app.
For a fresh Expo app, `init -t expo` uses the official `create-expo-app`
default shell, then overlays the Convex client/provider wiring plus one native
messages screen. It is still fresh scaffold only for app ownership, but Expo
does support the default `add auth` scaffold on top of that baseline. Existing
Expo app adoption is still out of scope.
On backend `convex`, `init --yes` finishes the first local Convex bootstrap in
the same command. Remote deployment-targeting flags stay `convex init` flags;
they do not trigger the local bootstrap wrapper.
Adopt the current app in place [#adopt-the-current-app-in-place]
Run `init --yes` inside a supported app when the app shell already exists and
you only want kitcn layered in place.
This path detects the current app, keeps the existing shell, and patches the
kitcn baseline into the right root. If the current directory is not a
supported app, `init` fails fast instead of guessing.
What init owns [#what-init-owns]
The rule is simple:
* `init` owns the kitcn integration layer
* the framework tool owns the framework shell
* `add` owns optional capabilities
That means `init` owns things like `convex/functions/*`, `convex/lib/*`,
`convex/shared/api.ts`, env bootstrap, provider wiring, and baseline scripts.
It does not pretend to own your whole application.
Backend bootstrap [#backend-bootstrap]
On backend `convex`, `init` also drives `convex init` before the first Better
Convex codegen pass. Deployment-targeting flags such as `--prod`,
`--preview-name`, `--deployment-name`, and `--env-file` are forwarded to
`convex init`.
Backend resolution order stays:
1. `--backend`
2. `backend` in `kitcn.json`
3. `convex`
Options [#options]
| Flag | Description |
| ------------------------------------------ | --------------------------------------------------------------- |
| `-t, --template ` | Fresh app template |
| `--cwd ` | Target directory, or parent when used with `--name` |
| `--name ` | New app directory name |
| `--backend ` | Backend CLI to drive |
| `--prod` | Forward to `convex init` |
| `--preview-name ` | Forward to `convex init` |
| `--deployment-name ` | Forward to `convex init` |
| `--env-file ` | Forward to `convex init` |
| `--yes` | Deterministic non-interactive mode |
| `--defaults` | Use default shadcn answers without widening the rest of `--yes` |
| `--json` | Machine-readable command output |
add [#add]
Once the baseline is in place, use `add` for feature layers.
```bash showLineNumbers
npx kitcn add auth --yes
npx kitcn add auth --schema --yes
npx kitcn add ratelimit --yes
npx kitcn add resend --yes
```
`add` is idempotent. It can preview the full repo delta, scaffold missing files,
patch existing kitcn seams, install package dependencies, run codegen,
and keep `plugins.lock.json` in sync.
Preview the plan before writing files:
```bash showLineNumbers
npx kitcn add resend --dry-run
npx kitcn add resend --diff convex/plugins/resend.ts
npx kitcn add resend --view convex/schema.ts
```
The preview surface covers:
* scaffold files
* env helper bootstrap
* `kitcn.json`
* `convex.json` when needed
* `schema.ts`
* `http.ts`
* cRPC wiring
* dependency install status
* codegen and `hooks.postAdd`
* env reminders
Auth presets [#auth-presets]
`add auth` has two honest modes.
The default path is kitcn-first:
```bash showLineNumbers
npx kitcn@latest init -t next --yes
npx kitcn add auth --yes
npx kitcn add auth --schema --yes
```
This keeps the kitcn auth stack, including the richer Next.js auth
surface.
Use the full `add auth` command for the first install. After changing plugins
or auth fields in `/auth.ts`, use `add auth --schema --yes`
when you only want the managed auth schema blocks and `plugins.lock.json`
refreshed.
The raw Convex path stays minimal:
```bash showLineNumbers
npx kitcn add auth --preset convex --yes
```
`--preset convex` keeps the app on the plain Convex backend path. It still
patches the supported frontend shell in place, but it stays conservative:
* Next.js gets provider and auth client wiring, not a larger kitcn auth
stack
* React/Vite gets provider and auth client wiring, not invented routes
* no fake page generation outside the supported shell boundary
* this path assumes the raw Convex app is already initialized
* `--schema` does not apply; re-run the full preset command when that auth
schema changes
Options [#options-1]
| Flag | Description |
| ----------------- | ------------------------------------------------ |
| `--yes` | Deterministic non-interactive mode |
| `--json` | Machine-readable command output |
| `--dry-run` | Show the full install plan without writing files |
| `--diff [path]` | Show unified diffs for planned file changes |
| `--view [path]` | Show planned file contents |
| `--schema` | Refresh only the managed auth schema scope |
| `--overwrite` | Overwrite existing changed files without prompt |
| `--no-codegen` | Skip automatic codegen after add |
| `--preset ` | Plugin preset override |
Files you edited [#files-you-edited]
`add` never replaces a scaffold file whose contents differ from the scaffold it
wrote. Such a file is kept and listed under `Refused files`, and the command
exits `1` because the plugin is only partially installed. Re-run with
`--overwrite` to replace it, or merge the change yourself.
With `--json`, `skipped` lists files that were already up to date, `refused`
lists files kept because they were edited, and `complete` is `false` whenever
anything was refused.
view [#view]
Use `view` when you want the resolved plugin plan without writing files.
```bash showLineNumbers
npx kitcn view auth
npx kitcn view resend --json
```
`view` shows the resolved preset, selection source, docs link, planned files,
and planned operations. It uses the same install-plan engine as `add`.
info [#info]
Use `info` when you want to inspect the current project state.
```bash showLineNumbers
npx kitcn info
npx kitcn info --json
```
It reports the resolved backend, config paths, installed versions, plugin state,
schema/lockfile mismatches, missing dependencies, and scaffold drift.
docs [#docs]
Use `docs` when you want local and public doc links from the CLI.
```bash showLineNumbers
npx kitcn docs cli
npx kitcn docs auth
npx kitcn docs resend --json
```
Supported topics include `cli`, `plugins`, `auth`, `orm`, `migrations`,
`resend`, and `ratelimit`.
Next Steps [#next-steps]
You now have the command map. From here, pick the next seam you actually need.
# Convex (/docs/comparison/convex)
Overview [#overview]
kitcn extends vanilla Convex with tRPC-style APIs and deep TanStack Query integration. It's not a replacement - it builds on Convex's real-time database:
| Layer | Vanilla Convex | kitcn Adds |
| ------ | ----------------------------- | --------------------------------------- |
| Server | `query`, `mutation`, `action` | Fluent builder, middleware, Zod |
| Client | `useQuery`, `useMutation` | TanStack Query, auth-aware skipping |
| SSR | `preloadQuery()` | `prefetch()`, `caller`, `HydrateClient` |
| Errors | `ConvexError` | `CRPCError` with HTTP codes |
ORM vs Vanilla Convex (ctx.orm vs ctx.db) [#orm-vs-vanilla-convex-ctxorm-vs-ctxdb]
Vanilla Convex gives low-level document queries and writes. The ORM adds a relational builder surface plus runtime guardrails.
Read Query Surface [#read-query-surface]
| Capability | Vanilla Convex (`ctx.db`) | ORM (`ctx.orm`) | Notes |
| ------------------------------------------------------------ | ------------------------------- | --------------- | ---------------------------------------------------------------------------- |
| Relational query builder (`findMany` / `findFirst` / `with`) | No | Yes | Drizzle-style read surface |
| `findFirstOrThrow()` | No | Yes | Throws when no row matches |
| Object `where` filters | Manual query/filter code | Yes | Includes comparison/logical/string operators |
| Callback `where` (`(table, ops) => ...`) | No equivalent | Yes | Drizzle-style callback helpers |
| Explicit index anchoring (`withIndex(name, range?)`) | Manual low-level index API | Yes | Required for predicate `where`; available for index-first planning |
| Predicate `where` with explicit `.withIndex(...)` | Manual only | Yes | Required for `ops.predicate(...)`; no implicit full scan fallback |
| Relation filters (`where: { relation: ... }`) | No | Yes | Nested relation predicates in reads |
| Eager relation loading (`with`) | Manual multi-query | Yes | Includes nested relation loading |
| Bounded scan controls (`allowFullScan`, `maxScan`) | Manual scan control | Yes | Guardrails for fallback paths and full-scan opt-in |
| Search index querying | Yes | Yes | ORM surface: `findMany({ search })` |
| Vector index querying | Yes | Yes | ORM surface: `findMany({ vectorSearch })` |
| Cursor pagination | Yes | Yes | ORM enforces typed `cursor + limit` usage |
| Offset pagination | Yes | Yes | Supported, but deep offsets are still expensive |
| Projection/computed fields (`columns`, `extras`) | Manual mapping | Yes | Computed/projection work runs post-fetch (no SQL computed columns) |
| Multi-field `orderBy` | Manual sort logic | Yes | First field drives cursor stability; secondary fields may sort post-fetch |
| Unsized non-paginated reads guardrail | No built-in ORM-style guardrail | Yes | Throws unless `limit`, cursor pagination, `defaultLimit`, or `allowFullScan` |
| Relation fan-out key cap | No built-in ORM-style guardrail | Yes | `relationFanOutMaxKeys` fail-fast guardrail |
| `findMany({ distinct })` | Manual dedup | No | Use `select().distinct({ fields })` pipeline when needed |
Aggregate and Count [#aggregate-and-count]
| Capability | Vanilla Convex (`ctx.db`) | ORM (`ctx.orm`) | Notes |
| --------------------------------------------------------------- | --------------------------------------------------- | ----------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
| `count()` | Manual/custom | Yes | Unfiltered path uses native Convex count syscall |
| `count({ where })` | Manual/custom | Yes | Strict no-scan; requires matching `aggregateIndex` |
| `count({ select: { _all, field } })` | No helper | Yes | Field counts require `.count(field)` metric declaration |
| `count({ orderBy, skip, take, cursor })` | Manual/custom | Yes | Windowed count with ordering and pagination bounds |
| `aggregate({ _count/_sum/_avg/_min/_max })` | No helper | Yes | Prisma-style aggregate blocks |
| Scan fallback for aggregate/count | Manual implementation choice | No | ORM aggregate/count never fall back to collect/take scan paths |
| Allowed aggregate/count filter subset | Manual/custom | `eq` / `in` / `isNull` / range + `AND` + finite safe `OR` rewrite | Strict no-scan v1 subset |
| `OR` in aggregate/count `where` | Manual/custom | Yes | Safe finite OR rewrite when each branch is index-plannable |
| Unsupported aggregate/count filters (`NOT` / string / relation) | Manual/custom | Blocked | Deterministic unsupported-filter errors |
| `aggregate({ orderBy/take/skip/cursor })` | Manual/custom | Partial | `orderBy/cursor` supported; `skip/take` is `_count`-only in v1 |
| Relation `_count` in `with` | No helper | Yes | `with: { _count: { relation: true } }` |
| Relation `_count` nested relation filter | Manual/custom | No | Blocked in v1 (`RELATION_COUNT_FILTER_UNSUPPORTED`) |
| Relation `_count` filtered `through()` relation | Manual/custom | Yes | Indexed lookups + no-scan-safe filter validation |
| Mutation `returning({ _count })` | No | Yes | Split selection + relation count loading on insert/update/delete |
| `groupBy({ by, _count/_sum/_avg/_min/_max })` | No | Yes | Finite-constrained `by` fields; `having`/`orderBy`/`skip`/`take`/`cursor` supported |
| Window analytics (`having` / window functions) | No | No | Deferred |
| Ranked access (`rankIndex` + `rank()`) | No | Yes | ORM-native `rankIndex` for leaderboards, random access, sorted pagination (no `@convex-dev/aggregate` dependency) |
| Aggregate read path overhead | Component calls often cross `ctx.runQuery` boundary | Direct aggregate/count read in current query | ORM avoids extra component read boundary for scalar aggregate/count paths |
Mutations, Constraints, and Delete Modes [#mutations-constraints-and-delete-modes]
| Capability | Vanilla Convex (`ctx.db`) | ORM (`ctx.orm`) | Notes |
| ------------------------------------------------------------ | ------------------------- | --------------- | ------------------------------------------------------------------- |
| Builder API (`insert` / `update` / `delete`) | No | Yes | Drizzle-style mutation builders |
| `.returning()` | No | Yes | Returns affected rows (always array) |
| Upserts (`onConflictDoNothing` / `onConflictDoUpdate`) | No helper | Yes | Runtime-enforced against unique constraints/indexes |
| Write safety guard (`update/delete` require `where`) | No | Yes | Must use `where` or opt in with `allowFullScan` |
| Paginated update/delete batches (`.paginate`) | Manual | Yes | Cursor-based write batching |
| Async mutation continuation scheduling | Manual | Yes | Large writes default to async continuation |
| Sync mode override | Manual | Yes | `.execute({ mode: 'sync' })` or schema default |
| Mutation safety/runtime defaults | Manual | Yes | `mutationBatchSize`, `mutationMaxRows`, schedule caps, byte budgets |
| Delete modes (hard / soft / scheduled) | Manual | Yes | Per-query mode + table defaults via `deletion(...)` |
| Runtime unique / FK / check enforcement | App code responsibility | Yes | Enforced for ORM mutations at runtime |
| Foreign-key actions (`cascade` / `set null` / `set default`) | App code responsibility | Yes | Runtime fan-out behavior in ORM layer |
Access Control and Lifecycle Hooks [#access-control-and-lifecycle-hooks]
| Capability | Vanilla Convex (`ctx.db`) | ORM (`ctx.orm`) | Notes |
| ------------------------------------------- | --------------------------------------- | ------------------------------- | ------------------------------------------------------------------------------------------------------------ |
| Table-level RLS policies | No | Yes | `rlsPolicy(...)` + `convexTable.withRLS(...)` |
| Role-scoped RLS (`to` + `roleResolver`) | No | Yes | Role rules require resolver wiring |
| Trusted bypass path | Direct access always bypasses ORM rules | `skipRules` and `ctx.db` bypass | Useful for admin/migration paths |
| Schema-level triggers | No | Yes | `defineTriggers(...)` with create/update/delete/change hooks |
| Trigger integration with aggregates | Manual | Yes | `aggregateIndex` and `rankIndex` backfill automatically; `change` triggers available for custom side effects |
| Trigger recursion controls | Manual | Yes | `ctx.db` wrapped + `ctx.innerDb` raw escape hatch |
| Cascade writes and child-table RLS re-check | Manual behavior | Not re-checked | ORM docs: cascade fan-out runs after root check and bypasses child-table RLS |
| Aggregate/count in RLS-restricted contexts | Manual/custom | Explicitly blocked | `COUNT_RLS_UNSUPPORTED` / `AGGREGATE_RLS_UNSUPPORTED` |
What Vanilla Convex Provides [#what-vanilla-convex-provides]
* **Real-time subscriptions** - WebSocket-based, automatic updates
* **TanStack Query adapter** - `@convex-dev/react-query` with `convexQuery()`
* **Type-safe functions** - `query`, `mutation`, `action` with Convex validators
* **SSR support** - `preloadQuery()` for server-side data fetching
* **React hooks** - `useQuery`, `useMutation` for reactive UI
What kitcn Adds [#what-kitcn-adds]
Server [#server]
| Feature | Description |
| -------------------------- | ----------------------------------------- |
| **Fluent Builder** | `.input().use().query()` chained API |
| **Zod Validation** | Schema reuse, refinements, transforms |
| **Destructured Params** | `{ ctx, input }` instead of `(ctx, args)` |
| **Middleware** | `.use()` chains with `next({ ctx })` |
| **Middleware Composition** | `.pipe()` for extending middleware |
| **Typed Metadata** | `.meta()` accessible in middleware |
| **Internal Procedures** | `.internal()` method |
| **Paginated Procedures** | `.paginated({ limit, item })` method |
| **CRPCError** | Typed codes with HTTP status mapping |
| **Server Caller** | Unified `caller.x.y({})` proxy |
Client [#client]
| Feature | Description |
| ------------------------ | ------------------------------------------------------- |
| **TanStack Query** | Full API: `isPending`, `isError`, `refetch()`, DevTools |
| **Query Keys** | `queryKey()`, `queryFilter()` for cache manipulation |
| **Subscription Control** | `subscribe: false` for one-time fetch |
| **Auth-aware** | `skipUnauth: true` auto-skips when unauthenticated |
| **Placeholder Data** | Skeleton UI support |
| **Type Inference** | `inferApiInputs`, `inferApiOutputs` helpers |
| **Mutation Callbacks** | `onSuccess`, `onError`, `onMutate`, `onSettled` |
| **Error Handling** | Typed errors with `error.data?.message` |
Next.js [#nextjs]
| Feature | Description |
| ------------------- | ------------------------------------------------- |
| **`prefetch()`** | Fire-and-forget, non-blocking, hydrated to client |
| **`HydrateClient`** | Automatic hydration, no prop drilling |
| **`caller`** | Direct server calls for RSC/API routes |
Infinite Queries [#infinite-queries]
| Convex | kitcn |
| ------------------------------- | --------------------------------------------------- |
| `results` | `data` (flattened array), `pages` (raw page arrays) |
| `status === 'LoadingFirstPage'` | `isLoading` |
| `status === 'CanLoadMore'` | `hasNextPage` |
| `status === 'LoadingMore'` | `isFetchingNextPage` |
| `loadMore(n)` | `fetchNextPage(n)` |
Syntax Comparison [#syntax-comparison]
Defining Queries [#defining-queries]
**Vanilla Convex:**
```ts showLineNumbers {4-5}
import { query } from 'convex/server';
import { v } from 'convex/values';
export const get = query({
args: { id: v.id('user') },
handler: async (ctx, args) => {
return ctx.db.get(args.id);
},
});
```
**kitcn:**
```ts showLineNumbers {4-6}
import * as z from 'zod';
import { publicQuery } from '../lib/crpc';
export const get = publicQuery
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
return ctx.db.get(input.id);
});
```
Using Queries [#using-queries]
**Vanilla Convex with TanStack Query:**
```tsx showLineNumbers {1-2,4}
import { useQuery } from '@tanstack/react-query';
import { convexQuery } from '@convex-dev/react-query';
const { data, isPending } = useQuery(convexQuery(api.user.get, { id }));
```
**kitcn:**
```tsx showLineNumbers {1-2,4-5,8,11}
import { useQuery } from '@tanstack/react-query';
import { useCRPC } from '@/lib/convex/crpc';
const crpc = useCRPC();
const { data, isPending } = useQuery(crpc.user.get.queryOptions({ id }));
// Auth-aware (skips when logged out)
const { data: me } = useQuery(crpc.user.me.queryOptions({}, { skipUnauth: true }));
// One-time fetch (no WebSocket subscription)
const { data } = useQuery(crpc.user.get.queryOptions({ id }, { subscribe: false }));
```
Mutations [#mutations]
**Vanilla Convex:**
```tsx showLineNumbers {1-2}
const mutate = useMutation(api.user.create);
await mutate({ name: 'John' });
```
**kitcn:**
```tsx showLineNumbers {1-7}
const crpc = useCRPC();
const { mutate, isPending } = useMutation(
crpc.user.create.mutationOptions({
onSuccess: () => toast.success('Created!'),
onError: (error) => toast.error(error.data?.message ?? 'Failed'),
})
);
```
Authentication Middleware [#authentication-middleware]
**Vanilla Convex (repeated in every function):**
```ts showLineNumbers {2-3}
export const me = query({
handler: async (ctx) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error('Unauthenticated');
return ctx.db.query('user').filter(q =>
q.eq(q.field('tokenIdentifier'), identity.tokenIdentifier)
).first();
},
});
```
**kitcn (reusable middleware):**
```ts showLineNumbers {2-6,9}
// Define once in crpc.ts
export const authQuery = c.query.use(async ({ ctx, next }) => {
const session = await getSession(ctx);
if (!session) throw new CRPCError({ code: 'UNAUTHORIZED' });
const user = await ctx.db.get(session.userId);
return next({ ctx: { ...ctx, user, userId: user.id } });
});
// Use everywhere
export const me = authQuery.query(async ({ ctx }) => ctx.user);
```
Server-Side Calls (Next.js) [#server-side-calls-nextjs]
**Vanilla Convex:**
```tsx showLineNumbers {1,4-5,9-10}
import { preloadQuery } from 'convex/nextjs';
export default async function Page() {
const preloaded = await preloadQuery(api.user.list);
return ;
}
// Client component must use usePreloadedQuery
function UserList({ preloadedUsers }) {
const users = usePreloadedQuery(preloadedUsers);
}
```
**kitcn:**
```tsx showLineNumbers {1,5,7-9,14}
import { prefetch, HydrateClient } from '@/lib/convex/server';
export default async function Page() {
// Non-blocking prefetch, hydrated to client
prefetch(crpc.user.list.queryOptions({}));
return (
);
}
// Client component uses standard useQuery
function UserList() {
const { data: users } = useQuery(crpc.user.list.queryOptions({}));
}
```
Error Handling [#error-handling]
**Vanilla Convex:**
```ts showLineNumbers
throw new ConvexError({ message: 'Not found' });
```
**kitcn:**
```ts showLineNumbers {2-6,9-10}
// Server - typed codes with HTTP mapping
throw new CRPCError({
code: 'NOT_FOUND', // Maps to HTTP 404
message: 'User not found',
cause: originalError,
});
// Client - error checking
import { isCRPCError, isCRPCErrorCode } from 'kitcn/react';
if (isCRPCErrorCode(error, 'NOT_FOUND')) {
// Handle 404
}
```
Infinite Queries [#infinite-queries-1]
**Vanilla Convex:**
```tsx showLineNumbers {1-5,7}
const { results, status, loadMore } = usePaginatedQuery(
api.user.list,
{},
{ initialNumItems: 10 }
);
const canLoadMore = status === 'CanLoadMore';
```
**kitcn:**
```tsx showLineNumbers {1,3-10}
import { skipToken } from '@tanstack/react-query';
const crpc = useCRPC();
const {
data, // Flattened array
pages, // Raw page arrays
hasNextPage,
fetchNextPage,
isFetchingNextPage,
} = useInfiniteQuery(
crpc.user.list.infiniteQueryOptions(enabled ? {} : skipToken)
);
```
When to Use kitcn [#when-to-use-kitcn]
**Use kitcn when you need:**
* Middleware chains for auth, validation, rate limiting
* Server-side calls without prop drilling (`prefetch`, `caller`)
* Auth-aware query skipping (`skipUnauth`)
* TanStack Query features (DevTools, cache manipulation, callbacks)
* Zod schemas shared between client and server
* tRPC-style fluent builder API
**Stick with vanilla Convex when:**
* Building a simple prototype
* Don't need middleware or server-side calls or real-time infinite queries
Next Steps [#next-steps]
# Drizzle (/docs/comparison/drizzle)
import { AlertTriangle } from "lucide-react"
This guide compares Drizzle and the ORM side-by-side. It focuses on API mapping and the behavioral differences that matter when moving from SQL to Convex. The mapping is aligned with Drizzle v1 relational APIs.
At A Glance [#at-a-glance]
**Same API surface:**
* Table builders and relation primitives (`convexTable`, `.relations(...)`, `one`, `many`)
* Relational query builder (`findMany`, `findFirst`, object/callback `where`, `orderBy`, `limit`, `offset`, `with`)
* Type inference (`InferSelectModel`, `InferInsertModel`, `$inferSelect`, `$inferInsert`)
* Mutation helpers (`insert`, `update`, `delete`, `returning`, `onConflictDoUpdate`)
**ORM extras:**
* `findFirstOrThrow()` (Prisma-style sugar when a row must exist)
**Different behavior:**
* `id` is auto-created (no manual `id` column)
* Fields are nullable by default (`text()` yields `string | null`)
* `columns` projection and string operators run post-fetch
* Relation filters and ordering for `many()` run post-fetch
* Unique and foreign keys (including FK actions) are enforced by ORM mutations, not SQL
* Cursor pagination uses the first `orderBy` field and requires indexes for stable ordering
**Not available in Convex:**
* SQL joins and raw SQL execution
* Database migrations and database-side constraints/triggers
* Custom SQL data types
* SQL-style `having` and window functions (planned separately)
Convex‑Ents Compatibility [#convexents-compatibility]
The ORM is designed as a successor to Convex‑Ents, but it follows Drizzle’s query‑builder model rather than Ent‑object APIs.
**What maps cleanly:**
* Relations and relational reads (`with`) replace `edge()`/`edgeX()` traversal
* Runtime constraints (unique / foreign key / check) are enforced by ORM mutations
* Soft and scheduled deletes are supported
* Many‑to‑many with data uses an explicit join table with payload fields
**What’s intentionally different:**
* No ent‑object API (`getX()`, `patch()`, lazy promises); use `ctx.orm.query.*` and mutation builders
* No edge methods on loaded rows; use relation loading + mutation builders
See [Migrate from native Convex](/docs/migrations/db) (for `ctx.db`) or [Migrate from Convex Ents](/docs/migrations/ents) (for `ctx.table`) for step‑by‑step mappings.
**Concurrency note:** uniqueness is best‑effort within a mutation; concurrent mutations can still race. Prefer `onConflictDoNothing()` / retries or serialize writes per key when strict guarantees are required.
Migration Checklist [#migration-checklist]
* [ ] Install `kitcn`
* [ ] Convert table definitions to `convexTable()` with column builders (`text`, `integer`, `boolean`, `id`)
* [ ] Replace manual `id` fields with Convex `id`
* [ ] Define relations on the default schema export with `.relations(...)` and load them with `with`
* [ ] Attach ORM to `ctx.orm` in context (create once)
* [ ] Keep `where`, `orderBy`, `limit`, `offset` shapes the same
* [ ] Move joins to relation loading (`with`)
Schema Definition [#schema-definition]
```ts showLineNumbers
import { pgTable, serial, text, integer, boolean } from 'drizzle-orm/pg-core';
const users = pgTable('users', {
id: serial('id').primaryKey(),
name: text('name').notNull(),
email: text('email').notNull(),
age: integer('age'),
isAdmin: boolean('is_admin').default(false),
});
const posts = pgTable('posts', {
id: serial('id').primaryKey(),
title: text('title').notNull(),
content: text('content').notNull(),
published: boolean('published').default(false),
userId: integer('user_id').references(() => users.id),
});
```
```ts showLineNumbers
import { convexTable, text, integer, boolean, id } from 'kitcn/orm';
const users = convexTable('users', {
// id is auto-created as Id<'users'>
name: text().notNull(),
email: text().notNull(),
age: integer(),
isAdmin: boolean(),
});
const posts = convexTable('posts', {
// id is auto-created as Id<'posts'>
title: text().notNull(),
content: text().notNull(),
published: boolean(),
userId: id('users'),
});
```
**Key changes:**
* IDs are auto-created as `id`
* Nullable by default unless `.notNull()` is used
* Foreign keys are `id('table')`
Relations [#relations]
```ts showLineNumbers
import { defineRelations } from 'drizzle-orm';
const relations = defineRelations({ users, posts }, (r) => ({
users: {
posts: r.many.posts(),
},
posts: {
author: r.one.users({
from: r.posts.userId,
to: r.users.id,
}),
},
}));
```
```ts showLineNumbers {1}
import { defineSchema } from 'kitcn/orm';
export default defineSchema({ users, posts }).relations((r) => ({
users: {
posts: r.many.posts(),
},
posts: {
author: r.one.users({
from: r.posts.userId,
to: r.users.id,
}),
},
}));
```
Queries [#queries]
```ts showLineNumbers
const users = await db.query.users.findMany();
const user = await db.query.users.findFirst({
where: { id: userId },
});
const admins = await db.query.users.findMany({
where: { role: 'admin' },
orderBy: { createdAt: 'desc' },
limit: 10,
});
```
```ts showLineNumbers {1}
const users = await ctx.orm.query.users.findMany({ limit: 50 });
const user = await ctx.orm.query.users.findFirst({
where: { id: userId },
});
const requiredUser = await ctx.orm.query.users.findFirstOrThrow({
where: { id: userId },
});
// Schema: index('by_role').on(t.role) on users table
const admins = await ctx.orm.query.users.findMany({
where: { role: 'admin' },
orderBy: { createdAt: 'desc' },
limit: 10,
});
const activeCount = await ctx.orm.query.users.count({
where: { role: 'admin' },
});
```
API Mapping [#api-mapping]
| Drizzle | ORM | Notes |
| ---------------------------------------------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| `pgTable()` / `mysqlTable()` / `sqliteTable()` | `convexTable()` | Same surface |
| `defineRelations()` | `defineSchema(...).relations(...)` | Same relation builder, attached to the default schema export |
| `db.query.*.findMany()` | `db.query.*.findMany()` | Same surface via `ctx.orm` |
| `db.query.*.findFirst()` | `db.query.*.findFirst()` | Same surface via `ctx.orm` |
| n/a | `db.query.*.findFirstOrThrow()` | ORM sugar; throws if no row exists |
| `where` object filters | `where` object filters | Same shape |
| `orderBy` | `orderBy` | Same shape, index-aware |
| `columns` | `columns` | Post-fetch projection |
| `with` | `with` | Relation loading |
| `insert/update/delete` | `insert/update/delete` | Same surface |
| `count` in query builder | `db.query.*.count()` | Strict no-scan filtered count via declarative `aggregateIndex` |
| `aggregate` in query builder | `db.query.*.aggregate({...})` | Prisma-style `_count/_sum/_avg/_min/_max`, strict no-scan via declarative `aggregateIndex` |
| `groupBy` in query builder | `db.query.*.groupBy({...})` | Prisma-style `by` + `_count/_sum/_avg/_min/_max`; finite `where` constraints on `by` fields; `having`/`orderBy`/`skip`/`take`/`cursor` supported |
| SQL joins | Not available | Use `with` |
| Raw SQL | Not available | Convex is document-based |
| Custom types | Not available | Use app-level encoding |
| Table introspection | `getTableColumns`, `getTableConfig` | Runtime metadata helpers |
Query Capability Matrix [#query-capability-matrix]
| Capability | Drizzle | kitcn ORM | Notes |
| -------------------------------------------------------- | ---------------------- | -------------------- | ----------------------------------------------------------------------------------- |
| `findMany` / `findFirst` | Yes | Yes | Same top-level shape |
| `findMany({ distinct })` | Yes (SQL `DISTINCT`) | No | Use `select().distinct({ fields })` pipeline when needed |
| `findFirstOrThrow` | No | Yes | ORM sugar |
| Object `where` | Yes | Yes | Same shape |
| Callback `where` (read queries) | Yes | Yes | ORM callback path is index-aware when possible |
| `with` relation loading | Yes | Yes | Same API style |
| Relation `_count` in `with` | No | Yes | Prisma-style `_count` relation map |
| Relation `_count` nested relation filter | n/a | No | Blocked in v1 (`RELATION_COUNT_FILTER_UNSUPPORTED`) |
| Relation `_count` filtered `through()` relation | n/a | Yes | Indexed lookups + no-scan-safe filter validation |
| Mutation `returning({ _count })` | No | Yes | Split selection + relation count loading on insert/update/delete |
| `count()` | Yes | Yes | ORM unfiltered count uses native Convex count syscall |
| `count({ where })` | Yes | Yes | ORM requires aggregateIndex coverage (strict no-scan) |
| `count({ where, select: { _all, field } })` | No | Yes | Field counts require `aggregateIndex(...).count(field)` |
| `count({ orderBy, skip, take, cursor })` | No | Yes | Windowed count with ordering and pagination bounds |
| `aggregate({ _count/_sum/_avg/_min/_max })` | Yes | Yes | ORM is Prisma-style and index-backed |
| `aggregate({ orderBy/take/skip/cursor })` | Yes | Partial | `orderBy/cursor` supported; `skip/take` is `_count`-only in v1 |
| `OR` in aggregate/count `where` | Yes | Yes | Safe finite OR rewrite when each branch is index-plannable |
| Advanced aggregate/count filters (`NOT/string/relation`) | Yes | No | Blocked in ORM v1 (`AGGREGATE_FILTER_UNSUPPORTED`) |
| Search query mode | SQL/extension-specific | Yes | `findMany({ search })` |
| Vector query mode | SQL/extension-specific | Yes | `findMany({ vectorSearch })` |
| Cursor pagination | Yes | Yes | ORM cursor is first `orderBy` field |
| Offset pagination | Yes | Yes | ORM supports `offset`, but deep offsets are not ideal |
| `groupBy({ by, _count/_sum/_avg/_min/_max })` | Yes | Yes | Finite-constrained `by` fields; `having`/`orderBy`/`skip`/`take`/`cursor` supported |
| Window analytics (`having` / window functions) | Yes | No | Deferred |
| Raw SQL / joins | Yes | No | Use relations + document query model |
| Aggregate read path overhead | n/a | Direct in-query path | ORM aggregate/count reads bucket tables directly (no component `runQuery` boundary) |
Not Available In Convex [#not-available-in-convex]
These Drizzle features are SQL-specific and do not map to Convex:
* SQL joins
* SQL subqueries and operators that depend on them (`exists`, `notExists`)
* SQL `having` and SQL window functions (`groupBy` is supported with finite constraints)
* Raw SQL execution
* RQB SQL-only features: `RAW` filters and SQL placeholders in `where`/`limit`/`offset` are not supported at runtime
* SQL database migrations and database-side constraints/triggers (use `kitcn migrate ...` for online data migrations)
* Custom SQL data types
Use relation filters for Convex-style existence checks:
```ts showLineNumbers
// Has at least one related post
await db.query.users.findMany({
where: { posts: true },
});
// Has no related posts
await db.query.users.findMany({
where: { NOT: { posts: true } },
});
```
For rank/window-like aggregation use cases (`at/indexOf/paginate/random`), use `rankIndex` in your schema and `rank()` in queries:
* Guide: [/docs/orm/queries/aggregates](/docs/orm/queries/aggregates)
Post-Fetch Behavior [#post-fetch-behavior]
These features keep Drizzle's API shape but run after data is fetched:
**Column selection**
```ts showLineNumbers
const users = await db.query.users.findMany({
columns: { name: true, email: true },
});
```
**String operators**
```ts showLineNumbers
const users = await db.query.users.findMany({
where: { email: { ilike: '%@example.com' } },
});
```
**Extras (computed fields)**
```ts showLineNumbers
const users = await db.query.users.findMany({
extras: {
emailDomain: (row) => row.email.split('@')[1]!,
},
});
```
**Relation filters on `many()`**
```ts showLineNumbers
const users = await db.query.users.findMany({
with: {
posts: {
where: { published: true },
orderBy: { createdAt: 'desc' },
limit: 5,
},
},
});
```
Indexes And Ordering [#indexes-and-ordering]
* `findMany({ cursor, limit })` relies on the first `orderBy` field for cursor ordering
* Sorting without an index falls back to `createdAt` ordering
* Post-fetch sorting (multi-field or non-indexable orderBy) sorts before `offset`/`limit`, so the ORM must read the full filtered candidate set first
* For these post-fetch sort paths, size non-paginated queries intentionally (`limit`, schema `defaultLimit`, or `allowFullScan`)
* Compound index planning follows prefix validity for index compilation. Non-leading-only predicates (for example `numLikes` on `[type, numLikes]`) can run, but they don't use the compound index prefix unless you also constrain the leading field (or explicitly anchor with `.withIndex(...)`)
* Add `index('...').on(t.field)` on fields used for filter or sort
```ts showLineNumbers
const posts = convexTable(
'posts',
{
title: text().notNull(),
numLikes: integer().notNull(),
},
(t) => [index('by_likes').on(t.numLikes)]
);
```
Constraints And Defaults [#constraints-and-defaults]
* Unique, foreign keys, and `check()` constraints are enforced only through ORM mutations
* Foreign key actions require an index on the referencing columns
* Relation loading requires indexes on relation fields (missing indexes throw unless `allowFullScan`)
* Relation loading fail-fasts when unique relation lookup keys exceed `defaults.relationFanOutMaxKeys` (default `1000`) unless `allowFullScan`
* Non-paginated `findMany()` must be explicitly sized (`limit`, cursor pagination (`cursor` + `limit`), schema `defaultLimit`, or `allowFullScan`)
* `many()` relation loading must be explicitly sized per parent (`with..limit`, schema `defaultLimit`, or `allowFullScan`)
* System tables (`_storage`, `_scheduled_functions`) are accessed via raw `db.system.get/query`, not `db.query.
.findMany()`
* Predicate `where` requires explicit `.withIndex(name, range?)` and runs post‑fetch via stream filtering
* Search mode (`findMany({ search })`) is relevance-ordered; `orderBy` is not supported
* Search mode supports base-table object `where`, but not predicate `where` or relation-based `where`
* Vector mode (`findMany({ vectorSearch })`) does not support `cursor`, `limit`, `maxScan`, `where`, `orderBy`, `.withIndex(...)`, or `offset`
* Update/delete without `where()` throws unless `allowFullScan`
* Update/delete matched rows are capped by `mutationMaxRows` (default `10000`) and collected in `mutationBatchSize` pages (default `400`)
* Async FK fan-out uses `mutationLeafBatchSize` for non-recursive actions (default `1600`)
* Async FK fan-out enforces measured-byte budget `mutationMaxBytesPerBatch` (default `2_097_152`)
* Async FK fan-out enforces schedule-call cap `mutationScheduleCallCap` (default `800`)
* For large update/delete workloads, use builder-level `paginate({ cursor, limit })` to process one mutation page at a time
* Async execution (`execute({ mode: 'async', batchSize?, delayMs? })` or `.executeAsync(...)`) runs the first update/delete batch inline and schedules remaining batches
* Async execution also applies to FK/cascade fan-out paths; large fan-out is paged and continued via scheduler instead of volume-fail-fast
* FK cascade fan-out is system-driven after root-row authorization; child-table RLS policies are not re-evaluated during fan-out
* Explicit async APIs (`execute({ mode: 'async' })` / `.executeAsync(...)`) are incompatible with builder-level `paginate(...)`; use one strategy per call
* Async execution is incompatible with resolved scheduled delete mode (`.scheduled(...)` or table-level `deletion('scheduled')`)
* Paged update/delete currently does not support multi-probe filter plans (`inArray`, some `OR`, complement ranges)
* Check constraints follow SQL-like NULL semantics: the check fails only when the expression is `false` (and passes on `unknown`)
* Defaults are applied only by ORM inserts/updates (`.default(...)`, `$defaultFn(...)`, `$onUpdateFn(...)`)
* Direct `ctx.db` writes bypass all ORM enforcement
**Default policy**
* Query sizing has no implicit fallback (no hidden row default unless you set `defaults.defaultLimit`).
* Relation fan-out guardrails default to `relationFanOutMaxKeys=1000`.
* Mutation safety has built-in guardrail defaults (`mutationBatchSize=400`, `mutationMaxRows=10000`).
* Async fan-out defaults: `mutationLeafBatchSize=1600`, `mutationMaxBytesPerBatch=2_097_152`, `mutationScheduleCallCap=800`.
* Non-paginated mutation mode defaults to async when scheduling is wired (`scheduler` + `scheduledMutationBatch`), otherwise sync; you can always override with `defaults.mutationExecutionMode`.
}>
**Uniqueness under concurrency**: runtime uniqueness checks are best‑effort within a mutation. Concurrent mutations can still race (including 1:1 relations).
Recommended patterns:
* Use `onConflictDoNothing()` / `onConflictDoUpdate()` for idempotent writes
* Retry on conflict when appropriate
* Serialize writes per unique key in application logic when strict guarantees are required
Soft & Scheduled Deletes [#soft--scheduled-deletes]
* Soft/scheduled deletes are **optional helpers** and not part of Drizzle
* Soft deletes require a `deletionTime` field in your table
* Table-level defaults are supported via `deletion('hard'|'soft'|'scheduled', options?)` in `convexTable(...)` extra config
* Per-query delete mode methods (`.hard()`, `.soft()`, `.scheduled(...)`) override table defaults
* Scheduled deletes require wiring `scheduler` + `scheduledDelete` when you build `ctx.orm`
* Async mutation batches require wiring `scheduler` + `scheduledMutationBatch` when you build `ctx.orm`
Performance Guidance [#performance-guidance]
* Index every field you filter or order by frequently
* Use search indexes for large text filtering
* Use vector indexes only for embedding search
* Prefer cursor pagination (`cursor` + `limit`) for infinite scroll and large datasets
* Avoid deep offset pagination for large tables
* Use `with` to avoid N+1 queries
Temporal Types On Convex [#temporal-types-on-convex]
For Drizzle parity, `date()` and `timestamp()` exist in the ORM, but storage follows Convex rules:
* `timestamp()` is the recommended type for point-in-time fields (`createdAt`, `updatedAt`, `expiresAt`).
* `date()` is calendar-only (`YYYY-MM-DD` semantics).
* Public ORM APIs use `createdAt`; Convex internal `_creationTime` is hidden.
* `timestamp().defaultNow()` yields `Date` in ORM reads while underlying Convex storage stays numeric.
Gotchas [#gotchas]
| Issue | Cause | Fix |
| -------------------------------------------- | --------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
| **`ctx.orm` is missing** | ORM not attached to context | Run `kitcn codegen` to generate `convex/functions/generated/server.ts` |
| **Convex internal IDs** | Convex stores `_id` internally | Use public `id` in ORM APIs |
| **Convex internal creation time** | Convex stores `_creationTime` internally | Use public `createdAt` in ORM APIs |
| **Nullable fields** | Nullable by default | Add `.notNull()` |
| **No SQL joins** | Convex is document-based | Use `with` relation loading |
| **Custom SQL types** | Not available | Use app-level encoding |
| Slow ordering | Missing index | Add `index('...').on(t.field)` in schema |
| `findMany({ cursor, limit })` order changes | No index on primary sort | Add index on first `orderBy` field |
| `findMany()` throws explicit sizing error | Non-paginated query has no `limit` and no schema `defaultLimit` | Add `limit`, use cursor pagination (`cursor` + `limit`), set `defaults.defaultLimit`, or opt in with `allowFullScan` |
| `like` or `ilike` is slow | Post-fetch filtering | Use search indexes |
| `vectorSearch` throws provider error | ORM context missing vector search | Pass `vectorSearch: ctx.vectorSearch` in ORM options |
| Relations not loading | Incomplete relation metadata | Define both relation sides or explicit `from`/`to` |
| `update()`/`delete()` exceeds row cap | Matched rows > `mutationMaxRows` | Narrow filters, increase `defaults.mutationMaxRows`, or switch to paged mutation execution |
| Paged update/delete throws multi-probe error | Filter compiles to multiple index probes | Rewrite filter to a single index range (or use non-paged mode with row cap) |
| Missing defaults | Direct `ctx.db` bypasses ORM defaults | Use ORM inserts or set defaults in code |
Schema Introspection [#schema-introspection]
The ORM exposes Drizzle-style helpers for runtime schema metadata:
```ts showLineNumbers
import { getTableColumns, getTableConfig } from 'kitcn/orm';
const columns = getTableColumns(users); // includes id/createdAt
const config = getTableConfig(users); // indexes/unique/fks/rls/checks
```
Next Steps [#next-steps]
Define tables, relations, and indexes
Learn filters, ordering, and pagination
# From Aggregate Component (/docs/migrations/aggregate)
import { InfoIcon, AlertTriangle } from "lucide-react"
This guide migrates from `@convex-dev/aggregate` component instances to ORM APIs:
* **Scalar metrics** (`count`, `sum`, `avg`, `min`, `max`) → `aggregateIndex` + `count()` / `aggregate()` / `groupBy()`
* **Ranked access** (`at`, `indexOf`, `paginate`, `min`, `max`, `random`) → `rankIndex` + `rank()`
Prerequisite [#prerequisite]
}>
Complete [DB migration](/docs/migrations/db) first. This guide assumes your app already runs on kitcn ORM runtime.
Concept Mapping [#concept-mapping]
| `@convex-dev/aggregate` | ORM equivalent | Notes |
| ------------------------------------------------ | ------------------------------------------------------------------------ | ------------------------------------------------------------------- |
| `app.use(aggregate, { name })` | No component wiring | Schema-declared indexes only |
| `namespace` | `aggregateIndex(...).on(...)` or `rankIndex(...).partitionBy(...)` | Each unique value combo = independent tree |
| `sortKey: (doc) => doc.score` | `rankIndex(...).orderBy(t.score)` | Supports multi-column + direction |
| `sumValue: (doc) => doc.score` | `rankIndex(...).sum(t.score)` | Optional weighted sum field |
| `bounds: { prefix, eq, lower, upper }` | `where: { field: value }` | Object filters instead of manual bounds |
| Manual `trigger()` wiring | Automatic | ORM lifecycle handles insert/update/delete |
| `count(ctx, { namespace, bounds })` | `ctx.orm.query.table.count({ where })` | Backed by `aggregateIndex` |
| `sum(ctx, { namespace, bounds })` | `ctx.orm.query.table.aggregate({ where, _sum })` | Backed by `aggregateIndex` |
| `at(ctx, offset, { namespace })` | `ctx.orm.query.table.rank(index, { where }).at(offset)` | Backed by `rankIndex` |
| `indexOf(ctx, key, { namespace })` | `ctx.orm.query.table.rank(index, { where }).indexOf({ id })` | Looks up by doc ID |
| `paginate(ctx, { namespace, cursor, pageSize })` | `ctx.orm.query.table.rank(index, { where }).paginate({ cursor, limit })` | Same cursor model |
| `min(ctx, { namespace })` | `rank(...).min()` | First row in rank order |
| `max(ctx, { namespace })` | `rank(...).max()` | Last row in rank order |
| `random(ctx, { namespace })` | `rank(...).random()` | Uniform sampling |
| `paginateNamespaces(ctx)` | Not available | Query partition values from your table directly |
| `clear(ctx)` / `clearAll(ctx)` | Not available | Use `aggregateBackfill --mode rebuild` |
| `DirectAggregate` | Not available | Model data as an ORM table, then use `aggregateIndex` / `rankIndex` |
Step 1: Remove Component Wiring [#step-1-remove-component-wiring]
```ts title="convex/convex.config.ts" showLineNumbers
import { defineApp } from 'convex/server';
import aggregate from '@convex-dev/aggregate/convex.config';
const app = defineApp();
app.use(aggregate, { name: 'aggregateByProject' });
app.use(aggregate, { name: 'leaderboard' });
export default app;
```
```ts title="convex/convex.config.ts" showLineNumbers
import { defineApp } from 'convex/server';
const app = defineApp();
export default app;
```
Remove `TableAggregate` / `DirectAggregate` instantiations and manual trigger wiring:
```ts title="convex/functions/aggregates.ts" showLineNumbers
import { TableAggregate } from '@convex-dev/aggregate';
import { components } from './_generated/api';
import { DataModel } from './_generated/dataModel';
export const leaderboard = new TableAggregate<{
Namespace: string;
Key: number;
DataModel: DataModel;
TableName: 'scores';
}>(components.leaderboard, {
namespace: (doc) => doc.gameId,
sortKey: (doc) => doc.score,
sumValue: (doc) => doc.score,
});
// Manual trigger wiring
triggers.register('scores', leaderboard.trigger());
```
```ts title="convex/functions/schema.ts" showLineNumbers
// No trigger wiring needed — ORM handles it automatically
```
Step 2: Declare Indexes [#step-2-declare-indexes]
Scalar metrics → aggregateIndex [#scalar-metrics--aggregateindex]
For `count()`, `sum()`, `avg()`, `min()`, `max()` reads:
```ts title="convex/functions/schema.ts" showLineNumbers
import {
aggregateIndex,
convexTable,
defineSchema,
integer,
text,
} from 'kitcn/orm';
export const todos = convexTable(
'todos',
{
projectId: text(),
status: text(),
effort: integer(),
priority: integer(),
},
(t) => [
// count-only (no metric fields needed)
aggregateIndex('by_project_status_count').on(t.projectId, t.status),
// full metric coverage
aggregateIndex('by_project_status')
.on(t.projectId, t.status)
.count(t.status)
.sum(t.effort)
.avg(t.effort)
.min(t.priority)
.max(t.priority),
// unfiltered (global) metrics
aggregateIndex('all_todos').all().sum(t.effort),
]
);
```
Ranked access → rankIndex [#ranked-access--rankindex]
For `at()`, `indexOf()`, `paginate()`, `min()`, `max()`, `random()` reads:
```ts title="convex/functions/schema.ts" showLineNumbers
import {
rankIndex,
convexTable,
defineSchema,
id,
integer,
text,
} from 'kitcn/orm';
export const scores = convexTable(
'scores',
{
gameId: id('games'),
score: integer(),
playerId: id('users'),
},
(t) => [
// partitionBy = old "namespace", orderBy = old "sortKey"
rankIndex('leaderboard')
.partitionBy(t.gameId)
.orderBy(t.score)
.sum(t.score),
// global (no partition)
rankIndex('global_leaderboard')
.all()
.orderBy(t.score),
]
);
export default defineSchema({ todos, scores });
```
}>
`rankIndex` `orderBy()` supports `integer()`, `timestamp()`, and `date()` columns only.
Step 3: Run Codegen [#step-3-run-codegen]
```bash
npx kitcn dev
```
Or scoped:
```bash
npx kitcn codegen --scope orm
```
Step 4: Deploy (Backfill Runs Automatically) [#step-4-deploy-backfill-runs-automatically]
```bash
npx kitcn deploy --prod
```
`kitcn deploy` runs `aggregateBackfill` in `resume` mode for both metric and rank indexes, then waits for all to report `READY`.
`resume` is idempotent:
* already-`READY` indexes are skipped
* `BUILDING` indexes continue
* `CLEARING` indexes (rebuild or prune in progress) finish draining first
* metric/rank additions are backfilled automatically
* metric removals are metadata-only updates
* key shape changes require rebuild:
```bash
npx kitcn aggregate rebuild --prod
```
Step 5: Switch Scalar Reads [#step-5-switch-scalar-reads]
```ts
// Count
const total = await aggregateTodos.count(ctx, {
namespace: projectId,
bounds: { prefix: ['done'] },
});
// Sum
const effortSum = await aggregateTodos.sum(ctx, {
namespace: projectId,
bounds: { prefix: ['done'] },
});
```
```ts
// Count
const total = await ctx.orm.query.todos.count({
where: { projectId, status: 'done' },
});
// Aggregate (sum + avg + min + max in one call)
const stats = await ctx.orm.query.todos.aggregate({
where: { projectId, status: 'done' },
_sum: { effort: true },
_avg: { effort: true },
_min: { priority: true },
_max: { priority: true },
_count: { _all: true, status: true },
});
```
Step 6: Switch Ranked Reads [#step-6-switch-ranked-reads]
```ts
// Top 10 leaderboard
const { page, cursor, isDone } = await leaderboard.paginate(ctx, {
namespace: gameId,
order: 'desc',
pageSize: 10,
});
// Player rank
const rank = await leaderboard.indexOf(ctx, playerScore, {
namespace: gameId,
id: playerId,
order: 'desc',
});
// Item at position
const third = await leaderboard.at(ctx, 2, { namespace: gameId });
// Random pick
const pick = await leaderboard.random(ctx, { namespace: gameId });
// Min / Max
const worst = await leaderboard.min(ctx, { namespace: gameId });
const best = await leaderboard.max(ctx, { namespace: gameId });
// Count / Sum
const count = await leaderboard.count(ctx, { namespace: gameId });
const total = await leaderboard.sum(ctx, { namespace: gameId });
```
```ts
const lb = ctx.orm.query.scores.rank('leaderboard', {
where: { gameId },
});
// Top 10 leaderboard
const { page, continueCursor, isDone } = await lb.paginate({
cursor: null,
limit: 10,
});
// Player rank
const rank = await lb.indexOf({ id: playerId });
// Item at position
const third = await lb.at(2);
// Random pick
const pick = await lb.random();
// Min / Max
const worst = await lb.min();
const best = await lb.max();
// Count / Sum
const count = await lb.count();
const total = await lb.sum();
```
}>
`indexOf` now takes `{ id }` (document ID) instead of a key + id combo. The rank is always in the index's declared order direction.
Step 7: Validate And Cut Over [#step-7-validate-and-cut-over]
* Compare old component values vs new ORM values on sampled traffic
* Wait for all required indexes to report `READY`
* Switch read paths fully
* Remove old `@convex-dev/aggregate` dependency and component instances
* Remove `_components` from `convex.config.ts` if no other components remain
API Reference [#api-reference]
Error Guidance [#error-guidance]
* `COUNT_NOT_INDEXED`: add matching `aggregateIndex(...).on(...)`
* `COUNT_INDEX_BUILDING`: continue backfill until `READY`
* `COUNT_FILTER_UNSUPPORTED`: unsupported filter shape for `count`
* `COUNT_RLS_UNSUPPORTED`: `count` blocked in RLS-restricted context (v1)
* `AGGREGATE_NOT_INDEXED`: add matching `aggregateIndex(...).on(...)` metric coverage (or `.all()` for unfiltered)
* `AGGREGATE_INDEX_BUILDING`: continue backfill until `READY`
* `AGGREGATE_FILTER_UNSUPPORTED`: unsupported filter shape for `aggregate(...)`
* `AGGREGATE_RLS_UNSUPPORTED`: `aggregate(...)` blocked in RLS-restricted context (v1)
* `RANK_NOT_INDEXED`: add matching `rankIndex(...).partitionBy(...)` or `.all()`
* `RANK_INDEX_BUILDING`: continue backfill until `READY`
* `RANK_RLS_UNSUPPORTED`: `rank()` blocked in RLS-restricted context (v1)
Rank Return Shape [#rank-return-shape]
Each rank query result item has:
```ts showLineNumbers
{
id: string; // document _id
key: unknown; // decoded sort key (single value or array if multi-column)
sumValue: number; // sum field value (or 1 if no sumField declared)
}
```
Not Migrated [#not-migrated]
These `@convex-dev/aggregate` features have no direct ORM equivalent:
| Feature | Workaround |
| --------------------------------------- | ---------------------------------------------------------------------------- |
| `DirectAggregate` (table-independent) | Model data as a table, then use `aggregateIndex` / `rankIndex` |
| `paginateNamespaces` / `iterNamespaces` | Query partition values from your source table |
| `iter` (async generator) | Loop `rank(...).paginate()` manually |
| `countBatch` / `sumBatch` / `atBatch` | Call methods individually (batching not yet supported) |
| `clear` / `clearAll` | `kitcn aggregate rebuild` |
| `makeRootLazy` / `rootLazy` | Not applicable (ORM manages B-tree configuration) |
| Negative `at(-1)` offsets | Not yet supported; use `max()` for last item |
| `bounds` with `id` tie-breaker | Not supported; use cursor pagination |
| `order: "desc"` on reads | Declare direction in `rankIndex(...).orderBy({ column, direction: 'desc' })` |
Next Steps [#next-steps]
# From Better Auth Component (/docs/migrations/auth)
import { AlertTriangle, InfoIcon } from "lucide-react"
import { Accordion, Accordions } from "fumadocs-ui/components/accordion"
import { Tab, Tabs } from "fumadocs-ui/components/tabs"
In this guide, we'll migrate from `@convex-dev/better-auth` (the component-based auth package) to `kitcn`. You'll remove the component pattern, update imports, and configure the new trigger system.
Overview [#overview]
The main differences between the packages:
| Aspect | @convex-dev/better-auth | kitcn |
| ------------- | --------------------------------- | -------------------------------------------------------------------------------------- |
| Architecture | Convex component pattern | Direct integration |
| Triggers | Not built-in | `triggers: { user, session }` |
| Auth Contract | `createAuthOptions/getAuth` split | `default defineAuth(() => ({ ...options, triggers }))` |
| DB Adapter | `authComponent.adapter(ctx)` | Generated `getAuth(ctx)` (context-aware adapter) |
| Provider | `ConvexBetterAuthProvider` | `ConvexAuthProvider` |
| React Utils | `AuthBoundary` | `createAuthMutations()` on the kitcn path, plain `authClient` on the raw Convex preset |
Let's migrate step by step.
Step 1: Remove Component Pattern [#step-1-remove-component-pattern]
Next, we'll remove the Convex component configuration. This is the biggest architectural change.
}>
**Production only:** Run component-data migration only if this app already has production data in `_components/betterAuth`.
If you are not migrating existing production data, skip this sub-step and continue with file deletion below.
}>
**Warning:** This ID remap flow is validated for the [latest Convex document ID format](https://github.com/get-convex/convex-backend/blob/main/crates/value/src/id_v6.rs). Older historical ID formats may exist and are not guaranteed by this script.
}>
**Test locally first:** Run this migration end-to-end against a local/dev copy before touching production. Convex internals can change over time, which can invalidate this migration.
}>
**IMPORTANT:** Importing with `--replace` is destructive and replaces all data in the target deployment.
**Prerequisite**
* Push/deploy your new app schema first so destination auth tables exist in app namespace.
* If those tables do not exist yet, migration/import fails with missing table ID errors.
**1) Export a backup zip**
```bash
npx convex export --path ./snapshot.zip
```
**2) Download migration utility files**
Create a local folder and download the migration utility files:
```bash
mkdir -p ./better-auth-component-migration
curl -fsSL https://raw.githubusercontent.com/udecode/kitcn/main/tooling/migrations/better-auth-component/cli.ts -o ./better-auth-component-migration/cli.ts
curl -fsSL https://raw.githubusercontent.com/udecode/kitcn/main/tooling/migrations/better-auth-component/id-v6.ts -o ./better-auth-component-migration/id-v6.ts
curl -fsSL https://raw.githubusercontent.com/udecode/kitcn/main/tooling/migrations/better-auth-component/migrate-snapshot.ts -o ./better-auth-component-migration/migrate-snapshot.ts
curl -fsSL https://raw.githubusercontent.com/udecode/kitcn/main/tooling/migrations/better-auth-component/zip.ts -o ./better-auth-component-migration/zip.ts
```
**3) Install migration zip dependencies**
```bash
npm i -D yazl yauzl
```
**4) Run the migration utility (creates a new `migration.zip`)**
```bash
node --experimental-strip-types ./better-auth-component-migration/cli.ts \
--snapshotZip ./snapshot.zip \
--outputZip ./migration.zip
```
Optional: migrate only specific tables:
```bash
node --experimental-strip-types ./better-auth-component-migration/cli.ts \
--snapshotZip ./snapshot.zip \
--outputZip ./migration.zip \
--filters=user,jwks
```
Optional: set a custom temp working directory:
```bash
node --experimental-strip-types ./better-auth-component-migration/cli.ts \
--snapshotZip ./snapshot.zip \
--outputZip ./migration.zip \
--workDir ./snapshot.temp
```
**5) Import manually**
```bash
npx convex import ./migration.zip --replace -y
```
**Files to delete:**
* `convex/betterAuth/` folder (entire directory)
* Remove `app.use(betterAuth)` from `convex/convex.config.ts`
```ts showLineNumbers {2,5}
import { defineApp } from "convex/server";
import betterAuth from "./betterAuth/convex.config";
const app = defineApp();
app.use(betterAuth); // Remove this line
export default app;
```
```ts showLineNumbers
import { defineApp } from "convex/server";
const app = defineApp();
export default app;
```
Step 2: Rebuild Auth [#step-2-rebuild-auth]
From here, choose the path that matches how you want to migrate the auth files.
If you only want auth and you are keeping this as a plain Convex app, use
the CLI path.
Run the auth scaffold [#run-the-auth-scaffold]
```bash
npx kitcn add auth --preset convex --yes
```
This scaffolds the auth files, runs the auth bootstrap, and keeps the app
on the plain Convex path.
Review the generated auth config [#review-the-generated-auth-config]
The generated `auth.config.ts` starts with `getAuthConfigProvider()`. Keep
that generated file as-is on the raw Convex preset. This path does not
switch over to the kitcn `getEnv()`-based config shape.
Done [#done]
That's it. The CLI path already updates the auth contract, HTTP routes,
client wiring, provider wiring, and schema integration for you without
pulling in the wider kitcn scaffold.
If you want full control over each file, work through these steps in
order.
Update Dependencies [#update-dependencies]
Install `kitcn`.
Update auth.config.ts [#update-authconfigts]
The auth config stays similar. We just need to ensure we're passing the static JWKS.
```ts showLineNumbers
import { getAuthConfigProvider } from "@convex-dev/better-auth/auth-config";
import type { AuthConfig } from "convex/server";
export default {
providers: [getAuthConfigProvider()],
} satisfies AuthConfig;
```
```ts showLineNumbers {4}
import { getAuthConfigProvider } from "kitcn/auth/config";
import type { AuthConfig } from "convex/server";
export default {
providers: [getAuthConfigProvider({ jwks: process.env.JWKS })],
} satisfies AuthConfig;
```
}>
Passing a static JWKS avoids database queries during token verification.
Generate Runtime [#generate-runtime]
If using cRPC, run `kitcn dev` (generates all runtime including cRPC modules):
```bash
npx kitcn dev
```
Otherwise, run a scoped generation pass:
```bash
npx kitcn codegen --scope auth
```
Migrate auth.ts [#migrate-authts]
This is the biggest change in the migration. We're replacing the component-based pattern — where you manually created a client, wired up adapters, and exported runtime helpers — with a single `defineAuth` contract. You declare your auth options and triggers in one place, and `kitcn` generates all the runtime for you.
Here's what the before and after looks like:
```ts showLineNumbers title="convex/auth.ts" {2,7-10,13,20,25-28}
import { components } from "./_generated/api";
import { createClient, GenericCtx } from "@convex-dev/better-auth";
import { convex } from "@convex-dev/better-auth/plugins";
import authSchema from "./betterAuth/schema";
import authConfig from "./auth.config";
import { betterAuth, type BetterAuthOptions } from "better-auth/minimal";
export const authComponent = createClient(
components.betterAuth,
{ local: { schema: authSchema }, verbose: false }
);
export const getAuthOptions = (ctx: GenericCtx) => ({
baseURL: process.env.SITE_URL,
database: authComponent.adapter(ctx),
// ... plugins and options
});
export const getAuth = (ctx: GenericCtx) =>
betterAuth(getAuthOptions(ctx));
export const { getAuthUser } = authComponent.clientApi();
export const getCurrentUser = query({
args: {},
handler: async (ctx) => {
return authComponent.safeGetAuthUser(ctx);
},
});
```
```ts showLineNumbers title="convex/functions/auth.ts" {3,6-20}
import { admin } from "better-auth/plugins";
import { convex } from "kitcn/auth";
import authConfig from "./auth.config";
import { defineAuth } from "./generated/auth";
export default defineAuth(() => ({
baseURL: process.env.SITE_URL!,
emailAndPassword: { enabled: true },
plugins: [
convex({
authConfig,
jwks: process.env.JWKS,
}),
admin(),
],
triggers: {
user: {
create: {
before: async (data, triggerCtx) => {
return { data };
},
after: async (user, triggerCtx) => {
},
},
},
},
}));
```
Notice how much simpler the new version is.
Here's a summary of what changed and why:
| Before | After | Why |
| ----------------------------------- | ------------------------------------------------------- | ------------------------------------------------------- |
| `createAuthOptions`/`getAuth` split | Single `defineAuth(() => ({ ... }))` | One contract, less boilerplate |
| Runtime exports from `auth.ts` | Generated runtime in `/generated/auth.ts` | Codegen handles wiring for you |
| `authComponent.adapter(ctx)` | Generated `getAuth(ctx)` with context-aware adapter | Automatic direct DB in queries, HTTP adapter in actions |
| Trigger signature `(ctx, ...)` | Trigger signatures `(..., ctx)` | Full parity with ORM `defineTriggers` callbacks |
| Component namespace routing | Generated contract + `internal.generated.*` handlers | No more component indirection |
}>
**Context-aware adapter:** The generated `getAuth(ctx)` automatically picks the right adapter. In queries and mutations, it uses direct DB access for performance. In actions and HTTP contexts, it uses the HTTP adapter with `ctx.run*` APIs.
Update http.ts [#update-httpts]
If you're not using cRPC, keep Convex's built-in `httpRouter` and use `registerRoutes`.
Use Hono only when you need cRPC or Hono middleware.
```ts showLineNumbers title="convex/http.ts"
import { httpRouter } from 'convex/server';
import { authComponent, getAuth } from './auth';
const http = httpRouter();
authComponent.registerRoutes(http, getAuth);
export default http;
```
Update the HTTP router:
```ts title="convex/functions/http.ts" showLineNumbers {1-4,6-13}
import { registerRoutes } from 'kitcn/auth/http';
import { httpRouter } from 'convex/server';
import { getAuth } from './generated/auth';
const http = httpRouter();
registerRoutes(http, getAuth, {
cors: {
allowedOrigins: [process.env.SITE_URL!],
},
});
export default http;
```
```ts title="convex/functions/http.ts" showLineNumbers {1-5,9-18,21}
import { authMiddleware } from 'kitcn/auth/http';
import { HttpRouterWithHono } from 'kitcn/server';
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { getAuth } from './generated/auth';
const app = new Hono();
// CORS for API routes
app.use(
'/api/*',
cors({
origin: process.env.SITE_URL!,
allowHeaders: ['Content-Type', 'Authorization', 'Better-Auth-Cookie'],
exposeHeaders: ['Set-Better-Auth-Cookie'],
credentials: true,
}),
);
// Better Auth middleware
app.use(authMiddleware(getAuth));
export default new HttpRouterWithHono(app);
```
```ts title="convex/functions/http.ts" showLineNumbers {1-6,10-19,22}
import { authMiddleware } from 'kitcn/auth/http';
import { createHttpRouter } from 'kitcn/server';
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { router } from '../lib/crpc';
import { getAuth } from './generated/auth';
const app = new Hono();
// CORS for API routes
app.use(
'/api/*',
cors({
origin: process.env.SITE_URL!,
allowHeaders: ['Content-Type', 'Authorization', 'Better-Auth-Cookie'],
exposeHeaders: ['Set-Better-Auth-Cookie'],
credentials: true,
})
);
// Better Auth middleware
app.use(authMiddleware(getAuth));
export const httpRouter = router({
// Add your routers here
});
export default createHttpRouter(app, httpRouter);
```
Update auth-client.ts [#update-auth-clientts]
Now we'll update the client-side auth setup.
```ts showLineNumbers title="lib/auth-client.tsx"
"use client";
import { convexClient } from "@convex-dev/better-auth/client/plugins";
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient({
plugins: [
convexClient(),
// ... other plugins
],
});
```
```ts showLineNumbers title="lib/convex/auth-client.ts" {3,12-16}
import { createAuthClient } from "better-auth/react";
import { convexClient } from "kitcn/auth/client";
import { createAuthMutations } from "kitcn/react";
export const authClient = createAuthClient({
plugins: [
convexClient(),
// ... other plugins
],
});
// Export mutation hooks for TanStack Query
export const {
useSignOutMutationOptions,
useSignInSocialMutationOptions,
useSignInMutationOptions,
useSignUpMutationOptions,
} = createAuthMutations(authClient);
```
The new `createAuthMutations` helper generates TanStack Query mutation options for common auth operations.
}>
This is the kitcn client surface. If you are keeping a plain Convex app
with `kitcn add auth --preset convex --yes`, keep the generated auth
client scaffold. That preset uses `createAuthClient(...)` plus `convexClient()`
and does not add `createAuthMutations`.
Update Provider [#update-provider]
Replace `ConvexBetterAuthProvider` with `ConvexAuthProvider`.
```tsx showLineNumbers title="app/ConvexClientProvider.tsx" {4,12-17}
"use client";
import { ConvexReactClient } from "convex/react";
import { ConvexBetterAuthProvider } from "@convex-dev/better-auth/react";
import { authClient } from "@/lib/auth-client";
const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!);
export function ConvexClientProvider({ children, initialToken }) {
return (
{children}
);
}
```
```tsx showLineNumbers title="lib/convex/convex-provider.tsx" {4,13-22}
"use client";
import { ConvexReactClient } from "convex/react";
import { ConvexAuthProvider } from "kitcn/auth/client";
import { authClient } from "@/lib/convex/auth-client";
import { useRouter } from "next/navigation";
const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!);
export function AppConvexProvider({ children, token }) {
const router = useRouter();
return (
{
router.push("/login");
}}
onQueryUnauthorized={({ queryName }) => {
router.push("/login");
}}
>
{children}
);
}
```
**New features:**
* `onMutationUnauthorized` - Handle auth errors on mutations
* `onQueryUnauthorized` - Handle auth errors on queries (includes `queryName` for debugging)
}>
If you are keeping the raw Convex preset, keep the provider shell the preset
patched in place. The raw path adds `ConvexAuthProvider`, but it does not
assume the larger kitcn provider layout.
Remove AuthBoundary [#remove-authboundary]
Remove `AuthBoundary` and use the provider's `onQueryUnauthorized` and `onMutationUnauthorized` callbacks instead.
Update Schema [#update-schema]
If you were using the auto-generated component schema, you'll now define auth tables in your main schema.
Refresh the full auth scaffold once:
```bash
npx kitcn add auth --yes
```
After changing plugins or auth fields in `/auth.ts`, refresh
only the auth-owned schema blocks:
```bash
npx kitcn add auth --schema --yes
```
Refresh the auth tables:
```bash
npx kitcn add auth --preset convex --yes
```
kitcn refreshes `/authSchema.ts` and patches `/schema.ts` to import and spread `authSchema`.
For fully manual table definitions, see [Auth Server Setup](/docs/auth/server#refresh-auth-owned-schema).
Step 3: Migrate Helper Methods [#step-3-migrate-helper-methods]
The component pattern provided several helper methods on `authComponent`. These now use direct `ctx.db` access.
getAuthUser / safeGetAuthUser [#getauthuser--safegetauthuser]
```ts showLineNumbers
// In a query or mutation
const user = await authComponent.getAuthUser(ctx); // throws if not found
const user = await authComponent.safeGetAuthUser(ctx); // returns null
```
```ts showLineNumbers {1,5-6,9-14}
import { getAuthUserIdentity } from 'kitcn/auth';
// In a query or mutation
export const myQuery = query({
handler: async (ctx) => {
const identity = await getAuthUserIdentity(ctx);
if (!identity) return null; // or throw
// Use ctx.db directly
const user = await ctx.db.get('user', identity.userId);
if (!user) throw new ConvexError("User not found");
return user;
},
});
```
getHeaders [#getheaders]
```ts showLineNumbers
const headers = await authComponent.getHeaders(ctx);
```
```ts showLineNumbers {1,4}
import { getHeaders } from 'kitcn/auth';
// In a query or mutation
const headers = await getHeaders(ctx);
```
getAuth [#getauth]
```ts showLineNumbers
const { auth, headers } = await authComponent.getAuth(getAuth, ctx);
const session = await auth.api.getSession({ headers });
```
```ts showLineNumbers {1,5-6}
import { getHeaders, getSession } from 'kitcn/auth';
// Option 1: Use helper for common operations
const session = await getSession(ctx);
// Option 2: Use Better Auth API directly
const headers = await getHeaders(ctx);
const auth = getAuth(ctx); // generated getAuth from /generated/auth.ts
const session = await auth.api.getSession({ headers });
```
clientApi().getAuthUser [#clientapigetauthuser]
```ts showLineNumbers title="convex/auth.ts"
export const { getAuthUser } = authComponent.clientApi();
```
```ts showLineNumbers title="convex/functions/queries.ts" {1,4-10}
import { getAuthUserIdentity } from 'kitcn/auth';
export const getAuthUser = query({
handler: async (ctx) => {
const identity = await getAuthUserIdentity(ctx);
if (!identity) throw new ConvexError("Unauthenticated");
return await ctx.db.get('user', identity.userId);
},
});
```
Migration Checklist [#migration-checklist]
* [ ] **Dependencies:** Install `kitcn`
* [ ] **convex.config.ts:** Remove `app.use(betterAuth)` line
* [ ] **Component data:** Migrate `betterAuth` component data to app namespace before deletion
* [ ] **betterAuth folder:** Delete the entire `convex/betterAuth/` directory
* [ ] **auth.ts:** Rewrite to `export default defineAuth(() => ({ ...options, triggers }))`
* [ ] **Helper methods:** Replace `authComponent.getAuthUser` etc with `ctx.db` access
* [ ] **Test:** Verify sign-in, sign-up, session persistence, and token refresh
kitcn path [#kitcn-path]
* [ ] Run `npx kitcn add auth --yes`
* [ ] Use `npx kitcn add auth --schema --yes` after auth table changes
* [ ] Use the `getEnv()`-based auth config shape
* [ ] Add `createAuthMutations`, update auth client imports
* [ ] Replace provider wiring with the kitcn auth-aware provider surface
* [ ] Use `npx kitcn dev` for ongoing local runtime and codegen
Raw Convex preset [#raw-convex-preset]
* [ ] Run `npx kitcn add auth --preset convex --yes`
* [ ] Keep generated `auth.config.ts` scaffolded with `getAuthConfigProvider()`
* [ ] Keep the generated plain auth client scaffold; do not add `createAuthMutations` unless you are choosing the richer kitcn client surface yourself
* [ ] Re-run the full preset command after auth schema changes; `--schema` does not apply here
* [ ] Keep the app on the plain Convex provider/router shape the preset patched in place
* [ ] Use `npx kitcn env push --prod` for production JWKS sync
Next.js Migration [#nextjs-migration]
If you're using Next.js, you can migrate server-side auth helpers to `kitcn/auth/nextjs` even when you do **not** use cRPC.
}>
If this app only uses Next.js auth helpers after migration, you can remove `@convex-dev/better-auth`.
Without cRPC [#without-crpc]
If you're not using cRPC, you can still use `kitcn/auth/nextjs`.
Only `handler` and `createCaller().getToken()` are needed.
**Server setup:**
```ts showLineNumbers title="lib/auth-server.ts"
import { api } from "@convex/api";
import { convexBetterAuth } from "kitcn/auth/nextjs";
export const { createCaller, handler } = convexBetterAuth({
api,
convexSiteUrl: process.env.NEXT_PUBLIC_CONVEX_SITE_URL!,
});
```
API route:
```ts showLineNumbers title="app/api/auth/[...all]/route.ts"
import { handler } from "@/lib/auth-server";
export const { GET, POST, OPTIONS } = handler;
```
Root layout:
```tsx showLineNumbers title="app/layout.tsx"
import { ConvexClientProvider } from "./ConvexClientProvider";
import { createCaller } from "@/lib/auth-server";
export default async function RootLayout({ children }) {
const token = await createCaller().getToken();
return (
{children}
);
}
```
}>
`kitcn/auth/nextjs` does not require cRPC. cRPC is only needed if you also use server callers beyond auth.
With cRPC [#with-crpc]
**Server setup:**
```ts showLineNumbers title="lib/auth-server.ts"
import { convexBetterAuthNextJs } from "@convex-dev/better-auth/nextjs";
export const { handler, getToken } = convexBetterAuthNextJs({
convexUrl: process.env.NEXT_PUBLIC_CONVEX_URL!,
convexSiteUrl: process.env.NEXT_PUBLIC_CONVEX_SITE_URL!,
});
```
```ts showLineNumbers title="lib/convex/server.ts" {3,5}
import { api } from '@convex/api';
import { convexBetterAuth } from 'kitcn/auth/nextjs';
export const { createContext, createCaller, handler } = convexBetterAuth({
api,
convexSiteUrl: process.env.NEXT_PUBLIC_CONVEX_SITE_URL!,
});
```
**Root layout:**
```tsx showLineNumbers title="app/layout.tsx" {3,6}
import { ConvexClientProvider } from "./ConvexClientProvider";
import { getToken } from "@/lib/auth-server";
export default async function RootLayout({ children }) {
const token = await getToken();
return (
{children}
);
}
```
```tsx showLineNumbers title="app/layout.tsx" {3,6}
import { AppConvexProvider } from "@/lib/convex/convex-provider";
import { createCaller } from "@/lib/convex/server";
export default async function RootLayout({ children }) {
const token = await createCaller().getToken();
return (
{children}
);
}
```
TanStack Start Migration [#tanstack-start-migration]
For TanStack Start, follow Steps 1-3 above for the core auth migration. This
section only covers the framework-specific wiring.
}>
TanStack Start uses the default kitcn auth flow: run `npx kitcn add auth --yes`
once, use `npx kitcn add auth --schema --yes` after auth table changes in
`/auth.ts`, and keep `npx kitcn dev` as the local Convex + codegen
loop. There is no separate `auth:generate` step on this path.
Server Setup [#server-setup]
Create server-side auth helpers using `kitcn/auth/start/server`:
```ts showLineNumbers title="src/lib/auth-server.ts"
import { api } from '@convex/api';
import { convexBetterAuthReactStart } from 'kitcn/auth/start/server';
export const { handler, getToken, createCaller } = convexBetterAuthReactStart({
api,
convexUrl: import.meta.env.VITE_CONVEX_URL!,
convexSiteUrl: import.meta.env.VITE_CONVEX_SITE_URL!,
});
```
API Route [#api-route]
Create the auth API route handler:
```ts showLineNumbers title="src/routes/api/auth/$.ts"
import { handler } from '@/lib/auth-server';
import { createFileRoute } from '@tanstack/react-router';
export const Route = createFileRoute('/api/auth/$')({
server: {
handlers: {
GET: ({ request }) => handler(request),
POST: ({ request }) => handler(request),
},
},
});
```
Router Setup [#router-setup]
Configure the router with `ConvexQueryClient`:
```tsx showLineNumbers title="src/router.tsx" {4,14-20,22-29}
import { createRouter as createTanStackRouter } from '@tanstack/react-router';
import { QueryClient, notifyManager } from '@tanstack/react-query';
import { setupRouterSsrQueryIntegration } from '@tanstack/react-router-ssr-query';
import { ConvexQueryClient } from '@convex-dev/react-query';
import { routeTree } from './routeTree.gen';
export function getRouter() {
if (typeof document !== 'undefined') {
notifyManager.setScheduler(window.requestAnimationFrame);
}
const convexUrl = import.meta.env.VITE_CONVEX_URL!;
const convexQueryClient = new ConvexQueryClient(convexUrl, {
expectAuth: true,
});
const queryClient = new QueryClient({
defaultOptions: {
queries: {
queryKeyHashFn: convexQueryClient.hashFn(),
queryFn: convexQueryClient.queryFn(),
},
},
});
convexQueryClient.connect(queryClient);
const router = createTanStackRouter({
routeTree,
context: { queryClient, convexQueryClient },
scrollRestoration: true,
});
setupRouterSsrQueryIntegration({ router, queryClient });
return router;
}
```
Root Route [#root-route]
Set up the root route with auth provider and SSR token:
```tsx showLineNumbers title="src/routes/__root.tsx" {5-6,11-13,18-32,36-43}
import {
Outlet,
createRootRouteWithContext,
useRouteContext,
} from '@tanstack/react-router';
import { QueryClient } from '@tanstack/react-query';
import { ConvexQueryClient } from '@convex-dev/react-query';
import { ConvexAuthProvider } from 'kitcn/auth/client';
import { syncConvexAuthForStartLoader } from 'kitcn/auth/start';
import { authClient } from '@/lib/auth-client';
import { createServerFn } from '@tanstack/react-start';
import { getToken } from '@/lib/auth-server';
const getAuth = createServerFn({ method: 'GET' }).handler(async () => {
return await getToken();
});
export const Route = createRootRouteWithContext<{
queryClient: QueryClient;
convexQueryClient: ConvexQueryClient;
}>()({
beforeLoad: async (ctx) => {
return await syncConvexAuthForStartLoader({
convex: ctx.context.convexQueryClient,
getToken: getAuth,
});
},
component: RootComponent,
});
function RootComponent() {
const context = useRouteContext({ from: Route.id });
return (
);
}
```
Protected Routes [#protected-routes]
Create a layout route for authenticated pages:
```tsx showLineNumbers title="src/routes/_authed.tsx"
import { createFileRoute, Outlet, redirect } from '@tanstack/react-router';
export const Route = createFileRoute('/_authed')({
beforeLoad: ({ context }) => {
if (!context.isAuthenticated) {
throw redirect({ to: '/sign-in' });
}
},
component: () => ,
});
```
Sign-In Route [#sign-in-route]
Redirect authenticated users away from sign-in:
```tsx showLineNumbers title="src/routes/sign-in.tsx"
import { createFileRoute, redirect } from '@tanstack/react-router';
import { SignIn } from '@/components/SignIn';
export const Route = createFileRoute('/sign-in')({
component: SignIn,
beforeLoad: ({ context }) => {
if (context.isAuthenticated) {
throw redirect({ to: '/' });
}
},
});
```
Sign Out [#sign-out]
Handle sign out with a page reload (required for `expectAuth`):
```tsx showLineNumbers title="src/components/SignOutButton.tsx"
import { authClient } from '@/lib/auth-client';
export function SignOutButton() {
const handleSignOut = async () => {
await authClient.signOut({
fetchOptions: {
onSuccess: () => {
// Reload required when using expectAuth
location.reload();
},
},
});
};
return ;
}
```
Troubleshooting [#troubleshooting]
"Auth runtime is disabled ..." [#auth-runtime-is-disabled-]
Generated runtime can be disabled when `/auth.ts` is missing or lacks a default export. `functionsDir` comes from `convex.json.functions` (default: `convex`), so scaffolded kitcn apps use `convex/functions/auth.ts`.
```ts showLineNumbers
import { defineAuth } from './generated/auth';
export default defineAuth(() => ({
// ...options
}));
```
Then run:
```bash
npx kitcn codegen
```
Better Auth CLI says config is invalid / cannot read auth config [#better-auth-cli-says-config-is-invalid--cannot-read-auth-config]
Use the kitcn auth scaffold command for your mode:
```bash showLineNumbers
npx kitcn add auth --yes
npx kitcn add auth --schema --yes
```
For raw Convex adoption, use:
```bash
npx kitcn add auth --preset convex --yes
```
This path assumes the raw Convex app is already initialized. If it still fails,
re-run `kitcn codegen` first so generated exports are up to date.
Next Steps [#next-steps]
Done! Your auth is now migrated. Here's what to explore next:
# From Convex (/docs/migrations/convex)
import { InfoIcon } from "lucide-react"
In this guide, we'll explore migrating from vanilla Convex to kitcn. You'll learn to convert functions incrementally, set up middleware, and migrate client hooks while keeping existing code working.
Overview [#overview]
Migrate incrementally - kitcn works alongside vanilla Convex. Convert functions one at a time:
| Aspect | What Changes |
| -------------- | ------------------------------------------------------- |
| Validators | `v.string()` → `z.string()` |
| Arguments | `args: { ... }` → `.input(z.object({ ... }))` |
| Handler params | `(ctx, args)` → `({ ctx, input })` |
| Errors | `ConvexError` → `CRPCError` with codes |
| Middleware | `customQuery` → `.use()` with `next()` |
| Client hooks | `useQuery(api.x)` → `useQuery(crpc.x.queryOptions({}))` |
Let's migrate step by step.
Step 1: Install [#step-1-install]
Step 2: Server Setup [#step-2-server-setup]
Create cRPC Builder [#create-crpc-builder]
```ts title="convex/lib/crpc.ts" showLineNumbers {10-11,16-18}
import { initCRPC } from 'kitcn/server';
import type { DataModel } from '../functions/_generated/dataModel';
const c = initCRPC
.dataModel()
.create();
export const publicQuery = c.query;
export const publicMutation = c.mutation;
export const publicAction = c.action;
```
Context - What Stays the Same [#context---what-stays-the-same]
Base context properties work identically:
* `ctx.db` - Database reader/writer
* `ctx.auth` - Authentication
* `ctx.storage` - File storage
* `ctx.scheduler` - Scheduler (mutations only)
Context - What's New [#context---whats-new]
* **Destructured params**: `{ ctx, input }` instead of `(ctx, args)`
* **Ents support**: `ctx.table()` if configured with `.context()`
Step 3: Migrate Procedures [#step-3-migrate-procedures]
Queries [#queries]
```ts showLineNumbers {3-4}
import { query } from 'convex/server';
import { v } from 'convex/values';
export const get = query({
args: { id: v.id('user') },
handler: async (ctx, args) => {
return ctx.db.get(args.id);
},
});
```
```ts showLineNumbers {1-3,5-7}
import * as z from 'zod';
import { publicQuery } from '../lib/crpc';
export const get = publicQuery
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
return ctx.db.get(input.id);
});
```
Mutations [#mutations]
```ts showLineNumbers {1-2}
export const create = mutation({
args: { name: v.string(), email: v.string() },
handler: async (ctx, args) => {
return ctx.db.insert('user', args);
},
});
```
```ts showLineNumbers {1-3}
export const create = publicMutation
.input(z.object({ name: z.string(), email: z.string() }))
.mutation(async ({ ctx, input }) => {
return ctx.db.insert('user', input);
});
```
Actions [#actions]
```ts showLineNumbers {1-2}
export const sendEmail = action({
args: { to: v.string(), subject: v.string() },
handler: async (ctx, args) => {
await fetch('https://api.email.com/send', { ... });
},
});
```
```ts showLineNumbers {1-3}
export const sendEmail = publicAction
.input(z.object({ to: z.string(), subject: z.string() }))
.action(async ({ ctx, input }) => {
await fetch('https://api.email.com/send', { ... });
});
```
Internal Procedures [#internal-procedures]
```ts showLineNumbers {1,3-4}
import { internalQuery } from 'convex/server';
export const internal_get = internalQuery({
args: { id: v.id('user') },
handler: async (ctx, args) => ctx.db.get(args.id),
});
```
```ts showLineNumbers {1-3}
export const internal_get = publicQuery
.internal()
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => ctx.db.get(input.id));
```
Paginated Procedures [#paginated-procedures]
```ts showLineNumbers {2,4-5}
import { query } from 'convex/server';
import { paginationOptsValidator } from 'convex/server';
export const list = query({
args: { paginationOpts: paginationOptsValidator },
handler: async (ctx, args) => {
return ctx.db.query('user').order('desc').paginate(args.paginationOpts);
},
});
```
```ts showLineNumbers {1-5,7-8}
const UserSchema = z.object({
id: z.string(),
name: z.string(),
email: z.string(),
});
export const list = publicQuery
.paginated({ limit: 20, item: UserSchema })
.query(async ({ ctx, input }) => {
return ctx.db.query('user').order('desc').paginate({ cursor: input.cursor, numItems: input.limit });
});
```
The output is automatically typed as `{ continueCursor, isDone, page: User[] }`.
Step 4: Add Middleware [#step-4-add-middleware]
Authentication [#authentication]
```ts showLineNumbers {2-3}
export const me = query({
handler: async (ctx) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error('Unauthenticated');
return ctx.db.query('user').filter(q =>
q.eq(q.field('tokenIdentifier'), identity.tokenIdentifier)
).first();
},
});
```
```ts title="convex/lib/crpc.ts" showLineNumbers {1-2,4-10,13}
import { getSession } from 'kitcn/auth';
import { CRPCError } from 'kitcn/server';
export const authQuery = c.query.use(async ({ ctx, next }) => {
const session = await getSession(ctx);
if (!session) {
throw new CRPCError({ code: 'UNAUTHORIZED' });
}
const user = await ctx.db.get(session.userId);
return next({ ctx: { ...ctx, user, userId: user.id } });
});
// Use everywhere
export const me = authQuery.query(async ({ ctx }) => ctx.user);
```
```ts showLineNumbers {1,3-10}
import { customQuery, customCtx } from 'convex-helpers/server/customFunctions';
const authQuery = customQuery(query, customCtx(async (ctx) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error('Unauthenticated');
const user = await ctx.db.query('user')
.filter(q => q.eq(q.field('tokenIdentifier'), identity.tokenIdentifier))
.first();
return { user };
}));
// Usage
export const me = authQuery({
args: {},
handler: async (ctx) => ctx.user,
});
```
```ts title="convex/lib/crpc.ts" showLineNumbers {1-2,4-10,13}
import { getSession } from 'kitcn/auth';
import { CRPCError } from 'kitcn/server';
export const authQuery = c.query.use(async ({ ctx, next }) => {
const session = await getSession(ctx);
if (!session) {
throw new CRPCError({ code: 'UNAUTHORIZED' });
}
const user = await ctx.db.get(session.userId);
return next({ ctx: { ...ctx, user, userId: user.id } });
});
// Use everywhere
export const me = authQuery.query(async ({ ctx }) => ctx.user);
```
Middleware Composition [#middleware-composition]
Chain multiple middleware with `.use()`:
```ts showLineNumbers {1,3-4,8-9}
const loggedQuery = c.query.use(async ({ ctx, next }) => {
console.log('Query started');
const result = await next({ ctx });
console.log('Query finished');
return result;
});
const authLoggedQuery = loggedQuery.use(async ({ ctx, next }) => {
// Auth check...
return next({ ctx: { ...ctx, userId } });
});
```
Extend existing middleware with `.pipe()`:
```ts showLineNumbers {1-5}
const adminQuery = authQuery.pipe(async ({ ctx, next }) => {
if (!ctx.user.isAdmin) {
throw new CRPCError({ code: 'FORBIDDEN' });
}
return next({ ctx });
});
```
Typed Metadata [#typed-metadata]
**New (typed metadata):**
```ts showLineNumbers {1,3,5-6,8-10}
const c = initCRPC
.dataModel()
.meta<{ ratelimit?: number }>()
.create({ ... });
export const limited = c.query
.meta({ ratelimit: 100 })
.use(async ({ ctx, meta, next }) => {
if (meta.ratelimit) {
await checkRatelimit(ctx, meta.ratelimit);
}
return next({ ctx });
})
.query(async ({ ctx }) => { ... });
```
Step 5: Error Handling [#step-5-error-handling]
Server - CRPCError [#server---crpcerror]
```ts showLineNumbers
throw new ConvexError({ message: 'Not found' });
```
```ts showLineNumbers {1,3-6}
import { CRPCError } from 'kitcn/server';
throw new CRPCError({
code: 'NOT_FOUND', // Maps to HTTP 404
message: 'User not found',
cause: originalError,
});
```
Available codes: `UNAUTHORIZED`, `FORBIDDEN`, `NOT_FOUND`, `BAD_REQUEST`, `CONFLICT`, `INTERNAL_SERVER_ERROR`, etc.
Client - Error States [#client---error-states]
```ts showLineNumbers
try {
await mutate(args);
} catch (error) {
console.error(error.message);
}
```
```tsx showLineNumbers {1-6,9}
const { mutate, error, isError } = useMutation(
crpc.user.create.mutationOptions({
onError: (error) => {
toast.error(error.data?.message ?? 'Failed');
},
})
);
// Or check error type
import { isCRPCErrorCode } from 'kitcn/react';
if (isCRPCErrorCode(error, 'NOT_FOUND')) {
// Handle 404
}
```
Step 6: Client Setup [#step-6-client-setup]
Provider Setup [#provider-setup]
```tsx title="src/app/providers.tsx" showLineNumbers {3-7,9,11-12,14-17}
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import {
ConvexReactClient,
getQueryClientSingleton,
getConvexQueryClientSingleton,
} from 'kitcn/react';
import { CRPCProvider } from '@/lib/convex/crpc';
const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!);
function createQueryClient() {
return new QueryClient({
defaultOptions: { queries: { staleTime: Infinity } },
});
}
export function Providers({ children }: { children: React.ReactNode }) {
const queryClient = getQueryClientSingleton(createQueryClient);
const convexQueryClient = getConvexQueryClientSingleton({
convex,
queryClient,
});
return (
{children}
);
}
```
Create cRPC Context [#create-crpc-context]
```tsx title="src/lib/convex/crpc.tsx" showLineNumbers {1-4,6-9}
import { api } from '@convex/api';
import { createCRPCContext } from 'kitcn/react';
export const { CRPCProvider, useCRPC, useCRPCClient } = createCRPCContext({
api,
convexSiteUrl: process.env.NEXT_PUBLIC_CONVEX_SITE_URL!,
});
```
Generate Metadata [#generate-metadata]
```bash showLineNumbers
npx kitcn dev
```
Step 7: Migrate Client Hooks [#step-7-migrate-client-hooks]
Queries [#queries-1]
```tsx showLineNumbers {1,3-4}
import { useQuery } from 'convex/react';
const user = useQuery(api.user.get, { id });
if (user === undefined) return
Loading...
;
```
```tsx showLineNumbers {1-2,4-5}
import { useQuery } from '@tanstack/react-query';
import { useCRPC } from '@/lib/convex/crpc';
const crpc = useCRPC();
const { data: user, isPending } = useQuery(crpc.user.get.queryOptions({ id }));
if (isPending) return
Loading...
;
```
**New options:**
```tsx showLineNumbers {2,5,8-10}
// Skip when not authenticated
const { data } = useQuery(crpc.user.me.queryOptions({}, { skipUnauth: true }));
// One-time fetch (no WebSocket subscription)
const { data } = useQuery(crpc.user.get.queryOptions({ id }, { subscribe: false }));
// Placeholder data for skeletons
const { data } = useQuery(crpc.user.get.queryOptions({ id }, {
placeholderData: { name: 'Loading...', email: '' },
}));
```
Mutations [#mutations-1]
```tsx showLineNumbers {1-2}
const createUser = useMutation(api.user.create);
await createUser({ name: 'John' });
```
```tsx showLineNumbers {1-7,9}
const { mutate, mutateAsync, isPending } = useMutation(
crpc.user.create.mutationOptions({
onSuccess: () => toast.success('Created!'),
onError: (error) => toast.error(error.data?.message ?? 'Failed'),
onMutate: () => { /* optimistic update */ },
onSettled: () => { /* cleanup */ },
})
);
mutate({ name: 'John' });
// or
await mutateAsync({ name: 'John' });
```
Infinite Queries [#infinite-queries]
```tsx showLineNumbers {1-5,7-8}
const { results, status, loadMore } = usePaginatedQuery(
api.user.list,
{},
{ initialNumItems: 10 }
);
const isLoading = status === 'LoadingFirstPage';
const canLoadMore = status === 'CanLoadMore';
```
```tsx showLineNumbers {1-2,4-10}
import { skipToken } from '@tanstack/react-query';
import { useInfiniteQuery } from 'kitcn/react';
const {
data, // Flattened array (was: results)
pages, // Raw page arrays
isLoading, // (was: status === 'LoadingFirstPage')
hasNextPage, // (was: status === 'CanLoadMore')
isFetchingNextPage, // (was: status === 'LoadingMore')
fetchNextPage, // (was: loadMore)
} = useInfiniteQuery(
crpc.user.list.infiniteQueryOptions(enabled ? {} : skipToken)
);
```
Step 8: Next.js/RSC (Optional) [#step-8-nextjsrsc-optional]
Three Server Patterns [#three-server-patterns]
**1. `prefetch()` - Fire-and-forget, non-blocking (NEW):**
```tsx showLineNumbers {1,4,6-8,13}
import { crpc, prefetch, HydrateClient } from '@/lib/convex/rsc';
export default async function Page() {
prefetch(crpc.user.list.queryOptions({}));
return (
);
}
// Client uses standard useQuery
function UserList() {
const { data } = useQuery(crpc.user.list.queryOptions({}));
}
```
**2. `caller` - Direct server calls:**
```tsx showLineNumbers {1,4-5}
import { caller } from '@/lib/convex/rsc';
export default async function Page() {
const users = await caller.user.list({});
return ;
}
```
**3. `preloadQuery()` - Awaited, data + hydration:**
```tsx showLineNumbers {1,4,6-10}
import { crpc, preloadQuery, HydrateClient } from '@/lib/convex/rsc';
export default async function Page() {
const users = await preloadQuery(crpc.user.list.queryOptions({}));
return (
);
}
```
Server + RSC Setup [#server--rsc-setup]
```tsx title="src/lib/convex/server.ts" showLineNumbers {1,4-8}
import { api } from '@convex/api';
import { createCallerFactory } from 'kitcn/server';
export const { createContext, createCaller } = createCallerFactory({
api,
convexSiteUrl: process.env.NEXT_PUBLIC_CONVEX_SITE_URL!,
});
```
```tsx title="src/lib/convex/rsc.tsx" showLineNumbers {3-4,6-8,11-12}
import 'server-only';
import { api } from '@convex/api';
import { createServerCRPCProxy } from 'kitcn/rsc';
import { cache } from 'react';
import { headers } from 'next/headers';
import { createCaller, createContext } from './server';
const createRSCContext = cache(async () => createContext({ headers: await headers() }));
export const caller = createCaller(createRSCContext);
export const crpc = createServerCRPCProxy({ api });
```
Quick Reference [#quick-reference]
| Aspect | Vanilla Convex | kitcn |
| ------------------ | ------------------------- | --------------------------------------- |
| **Validators** | `v.string()` | `z.string()` |
| **Arguments** | `args: { ... }` | `.input(z.object({ ... }))` |
| **Handler params** | `(ctx, args)` | `({ ctx, input })` |
| **Internal** | `internalQuery()` | `.internal().query()` |
| **Paginated** | `paginationOptsValidator` | `.paginated({ limit, item })` |
| **Errors** | `ConvexError` | `CRPCError` with codes |
| **Middleware** | `customQuery`/`customCtx` | `.use()` with `next()` |
| **Client hooks** | `useQuery(api.x)` | `useQuery(crpc.x.queryOptions({}))` |
| **Loading state** | `data === undefined` | `isPending` |
| **Mutations** | `useMutation(api.x)` | `useMutation(crpc.x.mutationOptions())` |
| **Infinite** | `usePaginatedQuery` | `useInfiniteQuery` |
| **Server calls** | `preloadQuery` | `prefetch`, `caller` |
Incremental Migration Tips [#incremental-migration-tips]
1. **Keep both patterns** - Vanilla and cRPC functions coexist
2. **Migrate by module** - Convert one file at a time
3. **Start with queries** - Lower risk than mutations
4. **Add middleware last** - After basic migration works
5. **Test as you go** - Each migration is isolated
```ts showLineNumbers {2,3}
// Both work in the same file
export const legacyList = query({ handler: async (ctx) => { ... } });
export const list = publicQuery.query(async ({ ctx }) => { ... });
```
Next Steps [#next-steps]
# From DB (/docs/migrations/db)
import { InfoIcon, AlertTriangle } from "lucide-react"
This guide shows how to migrate from native Convex database access (`ctx.db`) to the ORM (`ctx.orm`) without losing Convex's strengths.
We'll go step by step, from schema wiring to reads, joins, filtering, pagination, search, vector search, and writes.
The "after" snippets are cRPC-first, but the same ORM calls work in vanilla Convex query/mutation handlers.
}>
If you're new to cRPC, start with [/docs/server/procedures](/docs/server/procedures).
In ORM APIs, use public system fields `id` and `createdAt`. Convex internal `_id` and `_creationTime` are not part of the public ORM surface.
Why migrate now [#why-migrate-now]
Native Convex is excellent: explicit index usage, reactive queries, simple transactional writes, and clean JavaScript control flow.
The ORM adds another layer for teams that want safer defaults and more expressive relational reads:
* Drizzle-style query and mutation builders.
* Relation loading with `with:` instead of manual join assembly.
* Runtime defaults, constraints, and RLS on ORM writes.
* Strong type inference across schema, queries, and writes.
You are not giving up raw power. `ctx.db` still exists and stays useful as an escape hatch.
Migration Checklist [#migration-checklist]
Use this rollout order so migration is predictable and low-risk:
* Define tables and indexes with `convexTable()` for query paths you already use.
* Define relations on the default schema export with `.relations(...)`.
* Attach ORM once in context as `ctx.orm`.
* Migrate read endpoints (`db.get`, `db.query`) first.
* Migrate relation-heavy endpoints (`manual joins` -> `with:`).
* Migrate pagination and search/vector endpoints.
* Migrate write endpoints (`insert/patch/replace/delete` -> ORM builders).
* Turn on guardrails (defaults, constraints, RLS) where needed.
* Keep `db.system` / `ctx.db` explicit for system tables and intentional raw paths.
Step 1: Prepare schema + ctx.orm once [#step-1-prepare-schema--ctxorm-once]
We'll start by setting up schema and context once. This keeps every later endpoint change small and mechanical.
Define tables, indexes, and relations [#define-tables-indexes-and-relations]
```ts title="convex/schema.ts" showLineNumbers {3-7,10-14,16-22}
import { convexTable, defineSchema, text, boolean, integer, id, index, uniqueIndex } from 'kitcn/orm';
export const users = convexTable('users', {
name: text().notNull(),
email: text().notNull(),
role: text(),
age: integer(),
}, (t) => [uniqueIndex('by_email').on(t.email)]);
export const posts = convexTable('posts', {
title: text().notNull(),
content: text().notNull(),
published: boolean(),
userId: id('users'),
}, (t) => [index('by_user').on(t.userId)]);
export default defineSchema({ users, posts }).relations((r) => ({
users: { posts: r.many.posts() },
posts: {
author: r.one.users({ from: r.posts.userId, to: r.users.id }),
},
}));
```
This mirrors native Convex indexing discipline: index the fields you filter and sort by.
Generate Runtime [#generate-runtime]
If using cRPC, run `kitcn dev` (generates all runtime including cRPC modules):
```bash
npx kitcn dev
```
Otherwise, run a scoped generation pass:
```bash
npx kitcn codegen --scope orm
```
Attach ORM to context once [#attach-orm-to-context-once]
`convex/functions/generated/server.ts` exports `initCRPC` pre-wired with ORM context:
```ts title="convex/lib/crpc.ts" showLineNumbers {1}
import { initCRPC } from '../functions/generated/server';
```
Every handler can now read through `ctx.orm`.
Use `createdAt: timestamp().notNull().defaultNow()` on tables where your ORM rows should expose `createdAt: Date`. Without an explicit column, system `createdAt` stays `number`.
Step 2: Migrate core reads [#step-2-migrate-core-reads]
Now we'll migrate the read paths you use every day: single-row lookups, lists, and filtered/sorted queries.
Read one document (db.get -> findFirst) [#read-one-document-dbget---findfirst]
```ts title="convex/functions/users.ts" showLineNumbers
import { query } from 'convex/server';
import { v } from 'convex/values';
export const getById = query({
args: { userId: v.id('users') },
handler: async (ctx, args) => {
return ctx.db.get('users', args.userId);
},
});
```
```ts title="convex/functions/users.ts" showLineNumbers {2-4,7-11}
import * as z from 'zod';
import { publicQuery } from '../lib/crpc';
export const getById = publicQuery
.input(z.object({ userId: z.string() }))
.query(async ({ ctx, input }) => {
return ctx.orm.query.users.findFirst({ where: { id: input.userId } });
});
```
The migration is direct: `id` equality through `where` is the ORM equivalent of a point lookup.
List/filter/order (db.query -> findMany) [#listfilterorder-dbquery---findmany]
```ts showLineNumbers {3-5}
const posts = await ctx.db
.query('posts')
.withIndex('by_user', (q) => q.eq('userId', args.userId))
.order('desc')
.take(20);
```
```ts showLineNumbers {2-5}
const posts = await ctx.orm.query.posts.findMany({
where: { userId: input.userId },
orderBy: { createdAt: 'desc' },
limit: 20,
});
```
In native Convex you always name the index with `withIndex(...)`. In ORM, index-backed plans are inferred from `where` + `orderBy` when possible, but the same index design rules still matter.
}>
Most `findMany()` calls must be explicitly sized: provide `limit`, use cursor pagination (`cursor` + `limit`), set schema `defaultLimit`, or explicitly opt in with `allowFullScan`. `id` / `id in [...]` lookups are the main exception.
Step 3: Replace manual joins with with: [#step-3-replace-manual-joins-with-with]
Native Convex has no SQL joins, so relation loading means manual multi-query assembly. ORM `with:` removes that plumbing while keeping explicit schema relations.
one() relation (with: { author: true }) [#one-relation-with--author-true-]
```ts showLineNumbers {2-7}
const posts = await ctx.orm.query.posts.findMany({
orderBy: { createdAt: 'desc' },
limit: 20,
with: { author: true },
});
```
```ts showLineNumbers {1,3-7,9-12}
const posts = await ctx.db.query('posts').order('desc').take(20);
const authorIds = [...new Set(posts.map((p) => p.userId))];
const authors = await Promise.all(authorIds.map((id) => ctx.db.get('users', id)));
const authorById = new Map(authors.filter(Boolean).map((u) => [u.id, u]));
const postsWithAuthor = posts.map((p) => ({
...p,
author: authorById.get(p.userId) ?? null,
}));
```
The manual pattern works, but `with:` keeps the same outcome with less boilerplate and clearer intent.
many() relation (with: { posts: { ... } }) [#many-relation-with--posts----]
```ts showLineNumbers {2-8}
const users = await ctx.orm.query.users.findMany({
limit: 10,
with: {
posts: { limit: 5, orderBy: { createdAt: 'desc' } },
},
});
```
```ts showLineNumbers {1-3,5-13,15-18}
// Schema: index('by_user').on(t.userId) on posts
const users = await ctx.db.query('users').take(10);
const postsByUserId = new Map(
await Promise.all(
users.map(async (u) => [
u.id,
await ctx.db
.query('posts')
.withIndex('by_user', (q) => q.eq('userId', u.id))
.order('desc')
.take(5),
])
)
);
const usersWithPosts = users.map((u) => ({
...u,
posts: postsByUserId.get(u.id) ?? [],
}));
```
Same data shape and same read bound: the per-parent `limit` and `orderBy` are pushed into `by_user`, so each user costs five document reads. ORM gives you relation loading as a first-class query option.
Sorting by a column the relation index does not order by is the exception. `by_user` sorts by creation time within a user, so `orderBy: { createdAt }` stays bounded, while `orderBy: { rank: 'desc' }` reads that user's posts and sorts them in memory. Put the sort column in the index — `index('by_user_rank').on(t.userId, t.rank)` — to keep the read bounded.
Nested many-to-many (join table + payload) [#nested-many-to-many-join-table--payload]
```ts showLineNumbers {2-10}
const usersWithGroups = await ctx.orm.query.users.findMany({
limit: 10,
with: {
memberships: {
columns: { role: true, joinedAt: true },
with: { group: true },
},
},
});
```
```ts showLineNumbers {4-12,14-18,20-26}
const users = await ctx.db.query('users').take(10);
const membershipsByUserId = new Map();
const allMemberships = [];
for (const u of users) {
const rows = await ctx.db
.query('memberships')
.withIndex('by_user', (q) => q.eq('userId', u.id))
.collect();
membershipsByUserId.set(u.id, rows);
allMemberships.push(...rows);
}
const groupIds = [...new Set(allMemberships.map((m) => m.groupId))];
const groups = await Promise.all(groupIds.map((id) => ctx.db.get('groups', id)));
const groupById = new Map(groups.filter(Boolean).map((g) => [g.id, g]));
const usersWithGroups = users.map((u) => ({
...u,
memberships: (membershipsByUserId.get(u.id) ?? []).map((m) => ({
role: m.role,
joinedAt: m.joinedAt,
group: groupById.get(m.groupId) ?? null,
})),
}));
```
That's the core migration win for relational reads: same result, much less custom join code.
Relation existence and cross-table filters [#relation-existence-and-cross-table-filters]
ORM can express relation-based filters directly:
```ts showLineNumbers
await ctx.orm.query.users.findMany({
where: { posts: true },
limit: 50,
});
await ctx.orm.query.users.findMany({
where: { posts: { published: true } },
limit: 50,
});
```
Native Convex equivalents are usually:
* Denormalize (`hasPosts`, `postCount`, `hasPublishedPosts`) and index those fields.
* Or run two-phase queries (query child table, dedupe parent IDs, load parents).
The denormalized path is usually the long-term scalable one.
Step 4: Filtering and index strategy [#step-4-filtering-and-index-strategy]
Now let's lock how to choose filter modes so migration stays fast at scale.
| Need | Native Convex | ORM |
| -------------------------------- | ------------------------------------------- | ---------------------------------------------------------------- |
| Indexed equality/range filtering | `withIndex("...")` range expression | object `where` (index-compiled when possible) |
| Complex JS predicate filtering | `.filter(...)` (table/index scan semantics) | callback `where` + `predicate(...)` + explicit `.withIndex(...)` |
| Full-text relevance search | `withSearchIndex(...)` | `findMany({ search })` |
| Vector similarity search | `ctx.vectorSearch(...)` in actions | `findMany({ vectorSearch })` (or native action pattern) |
Here are the guardrails to keep in mind during migration:
* Native Convex requires explicit `withIndex(...)` to force index usage.
* ORM compiles index plans from `where`/`orderBy` where possible, but still enforces sizing and scan guardrails.
* Compound indexes follow prefix rules in both worlds: constrain leading index fields before trailing fields.
* Predicate `where` in ORM must include explicit `.withIndex(...)` and should be bounded with `maxScan` in cursor mode.
Example predicate `where` in ORM:
```ts showLineNumbers {3-4}
const page = await ctx.orm.query.users
.withIndex('by_email')
.findMany({
where: (_users, { predicate }) =>
predicate((u) => u.email.endsWith('@example.com')),
cursor: null,
limit: 20,
maxScan: 500,
});
```
This makes scan behavior explicit instead of accidental.
Step 5: Pagination migration [#step-5-pagination-migration]
Next we'll migrate pagination. The mapping is straightforward: native `paginate` becomes ORM cursor paging.
```ts title="convex/functions/posts.ts" showLineNumbers {3-7,11}
import { query } from 'convex/server';
import { paginationOptsValidator } from 'convex/server';
export const list = query({
args: { paginationOpts: paginationOptsValidator },
handler: async (ctx, args) => {
return ctx.db.query('posts').order('desc').paginate(args.paginationOpts);
},
});
```
```ts title="convex/functions/posts.ts" showLineNumbers {2-4,8-12}
import * as z from 'zod';
import { publicQuery } from '../lib/crpc';
export const list = publicQuery
.input(z.object({ cursor: z.string().nullable() }))
.query(async ({ ctx, input }) => {
return ctx.orm.query.posts.findMany({
orderBy: { createdAt: 'desc' },
cursor: input.cursor,
limit: 20,
});
});
```
The ORM page shape matches Convex pagination shape: `{ page, continueCursor, isDone }`.
If you want standardized pagination input/output at the procedure layer, use `publicQuery.paginated(...)`.
}>
Like native Convex pagination, page boundaries are reactive. As rows are inserted or removed, a page can shrink or grow between refreshes.
Step 6: Full-text search migration [#step-6-full-text-search-migration]
We'll now map native `withSearchIndex` calls to ORM search mode.
```ts showLineNumbers {4-6}
const messages = await ctx.db
.query('messages')
.withSearchIndex('search_body', (q) =>
q.search('body', args.query).eq('channel', '#general')
)
.take(10);
```
```ts showLineNumbers {2-9}
const messages = await ctx.orm.query.messages.findMany({
search: {
index: 'search_body',
query: input.query,
filters: { channel: '#general' },
},
cursor: null,
limit: 10,
});
```
Search stays relevance-ordered in both models. You should not expect custom `orderBy` in search mode.
Step 7: Vector search migration [#step-7-vector-search-migration]
Vector search in native Convex is action-only. The migration keeps that shape: run vector search in an action, then hydrate documents via a query.
```ts title="convex/functions/posts.ts" showLineNumbers {2-4,14-19}
import { v } from 'convex/values';
import { action, internalQuery } from 'convex/server';
import { internal } from './_generated/api';
export const fetchByIds = internalQuery({
args: { ids: v.array(v.id('posts')) },
handler: async (ctx, args) => {
const rows = [];
for (const id of args.ids) {
const doc = await ctx.db.get('posts', id);
if (doc) rows.push(doc);
}
return rows;
},
});
export const similarPosts = action({
args: { embedding: v.array(v.number()) },
handler: async (ctx, args) => {
const hits = await ctx.vectorSearch('posts', 'embedding_vec', {
vector: args.embedding,
limit: 10,
});
return ctx.runQuery(internal.posts.fetchByIds, {
ids: hits.map((h) => h.id),
});
},
});
```
```ts title="convex/functions/posts.ts" showLineNumbers {2-7,16-28}
import * as z from 'zod';
import { publicAction, privateQuery } from '../lib/crpc';
import { createPostsCaller } from './generated/posts.runtime';
export const fetchByIds = privateQuery
.input(z.object({ ids: z.array(z.string()) }))
.query(async ({ ctx, input }) => {
return ctx.orm.query.posts.findMany({
where: { id: { in: input.ids } },
limit: input.ids.length,
with: { author: true },
});
});
export const similarPosts = publicAction
.input(z.object({ embedding: z.array(z.number()) }))
.action(async ({ ctx, input }) => {
const hits = await ctx.vectorSearch('posts', 'embedding_vec', {
vector: input.embedding,
limit: 10,
filter: (q) => q.eq('type', 'news'),
});
const caller = createPostsCaller(ctx);
const rows = await caller.fetchByIds({
ids: hits.map((h) => h.id),
});
const scoreById = new Map(hits.map((h) => [String(h.id), h._score]));
return rows.map((row) => ({
...row,
_score: scoreById.get(String(row.id)) ?? 0,
}));
});
```
This preserves the native Convex action pattern and adds cleaner ORM hydration logic.
Step 8: Write migration semantics [#step-8-write-migration-semantics]
Now we'll map writes carefully, because semantics differ most here.
Insert (db.insert -> insert().values()) [#insert-dbinsert---insertvalues]
```ts title="convex/functions/users.ts" showLineNumbers
import { mutation } from 'convex/server';
import { v } from 'convex/values';
export const createUser = mutation({
args: { name: v.string(), email: v.string() },
handler: async (ctx, args) => {
return ctx.db.insert('users', { name: args.name, email: args.email });
},
});
```
```ts title="convex/functions/users.ts" showLineNumbers {2-6,11-18}
import * as z from 'zod';
import { publicMutation } from '../lib/crpc';
import { users } from '../schema';
export const createUser = publicMutation
.input(z.object({ name: z.string(), email: z.string().email() }))
.mutation(async ({ ctx, input }) => {
const [row] = await ctx.orm
.insert(users)
.values({ name: input.name, email: input.email })
.returning({ id: users.id });
return row.id;
});
```
Native `insert` returns an ID directly. ORM returns `void` unless you call `.returning(...)`.
Patch/replace semantics (db.patch / db.replace -> update().set().where()) [#patchreplace-semantics-dbpatch--dbreplace---updatesetwhere]
```ts showLineNumbers {2}
import { eq, unsetToken } from 'kitcn/orm';
```
```ts showLineNumbers
const { userId, ...patch } = input;
// Patch-style update: undefined keys are ignored.
await ctx.orm.update(users).set(patch).where(eq(users.id, userId));
// Native Convex equivalent of removing a field via patch({ field: undefined }):
await ctx.orm.update(users).set({ nickname: unsetToken }).where(eq(users.id, userId));
// Replace-style update: set all fields explicitly.
await ctx.orm
.update(users)
.set({ name: input.name, email: input.email, role: input.role })
.where(eq(users.id, userId));
```
}>
Native Convex treats `patch({ field: undefined })` as field removal. ORM uses `unsetToken` for that explicit unset behavior.
Also watch output shapes during migration: nullable ORM columns come back as `null`, while native Convex often represents missing fields as `undefined`.
Delete (db.delete -> delete().where()) [#delete-dbdelete---deletewhere]
```ts showLineNumbers
await ctx.db.delete('users', args.userId);
```
```ts showLineNumbers
await ctx.orm.delete(users).where(eq(users.id, input.userId));
```
Upsert conflict mapping [#upsert-conflict-mapping]
```ts showLineNumbers {1-7}
const [row] = await ctx.orm
.insert(users)
.values({ email: input.email, name: input.name })
.onConflictDoUpdate({
target: users.email,
set: { name: input.name },
})
.returning({ id: users.id });
```
```ts showLineNumbers {1-4,6-11}
// Schema: index('by_email').on(t.email) on users
const existing = await ctx.db
.query('users')
.withIndex('by_email', (q) => q.eq('email', input.email))
.unique();
if (existing) {
await ctx.db.patch('users', existing.id, { name: input.name });
return existing.id;
}
return await ctx.db.insert('users', { email: input.email, name: input.name });
```
Step 9: Guarantees and caveats [#step-9-guarantees-and-caveats]
Let's close with the guarantees you gain and the caveats you still need to own.
What ORM adds [#what-orm-adds]
* Runtime constraint/default enforcement on ORM mutation paths.
* Optional RLS policy enforcement on ORM paths.
* Drizzle-style mutation/query ergonomics.
What still bypasses enforcement [#what-still-bypasses-enforcement]
`ctx.db` is still raw native access and bypasses ORM constraints/defaults/RLS.
}>
Use `ctx.db` intentionally. If you mix `ctx.db` writes into an ORM-migrated codebase, you're opting out of ORM enforcement on those paths.
Uniqueness under concurrency [#uniqueness-under-concurrency]
Runtime uniqueness checks are best-effort within one mutation. Concurrent mutations can still race.
Use idempotent patterns (`onConflictDoNothing` / `onConflictDoUpdate`), retries, or serialize writes by key when strict guarantees are required.
System-table parity [#system-table-parity]
System tables remain raw passthrough access in both models:
```ts showLineNumbers {2-8}
const files = await ctx.orm.system.query('_storage').take(20);
const jobs = await ctx.orm.system.query('_scheduled_functions').collect();
```
This maps directly to native `ctx.db.system.get/query` semantics.
Rollout playbook [#rollout-playbook]
If you're migrating an existing app, this order keeps risk low and progress measurable.
1. Migrate read-only endpoints that already have clear index paths.
2. Migrate relation-heavy endpoints and delete manual join assembly.
3. Migrate writes table by table (`insert`, then `update`, then `delete`, then upserts).
4. Migrate search endpoints.
5. Migrate vector action flows.
6. Enable stricter defaults/constraints/RLS where needed.
Done criteria [#done-criteria]
Use this checklist to know migration is complete:
* [ ] App tables read through `ctx.orm.query.*` by default.
* [ ] List endpoints are explicitly sized (`limit` or cursor pagination).
* [ ] Manual join assembly is replaced by `with:` where practical.
* [ ] Patch/remove semantics are explicit (`unsetToken` where needed).
* [ ] Search/vector endpoints follow mode constraints and documented patterns.
* [ ] Any remaining `ctx.db` usage is intentional and documented in code comments.
That's it. You now have an ORM migration path that preserves Convex strengths while reducing repetitive query and write plumbing.
Next Steps [#next-steps]
# From Ents (/docs/migrations/ents)
import { InfoIcon, AlertTriangle } from "lucide-react"
Convex Ents is in maintenance mode. The ORM is the successor: a Drizzle‑style query builder with runtime constraints and RLS on top of Convex. This guide maps Ents patterns to ORM equivalents.
In ORM APIs, system fields are public as `id` and `createdAt`. Internal Convex fields (`_id`, `_creationTime`) are hidden from the ORM surface.
Migration Checklist [#migration-checklist]
* Replace `defineEntSchema` / `defineEnt` with `convexTable()` + `defineSchema()`
* Convert edges to schema `.relations(...)`
* Attach ORM to `ctx.orm` in your context (create once)
* Replace edge traversal (`edge()`) with `with:` or explicit join-table queries
* Replace ent mutations with `insert` / `update` / `delete`
* Replace Ents rules with `rlsPolicy` (runtime RLS)
* Revisit cascades, uniqueness under concurrency, and search
Schema & Relations [#schema--relations]
```ts title="convex/schema.ts" showLineNumbers
import { defineEnt, defineEntSchema } from 'convex-ents';
import { v } from 'convex/values';
const schema = defineEntSchema({
users: defineEnt({
name: v.string(),
email: v.string(),
})
.field('username', v.string(), { unique: true })
.edges('posts', { ref: true }),
posts: defineEnt({
title: v.string(),
body: v.string(),
}).edge('user', { field: 'userId' }),
});
```
```ts title="convex/schema.ts" showLineNumbers {12-15,18-20,22-25}
import {
convexTable,
defineSchema,
text,
id,
uniqueIndex,
index,
} from 'kitcn/orm';
export const users = convexTable(
'users',
{ name: text().notNull(), email: text().notNull(), username: text().notNull() },
(t) => [uniqueIndex('by_username').on(t.username)]
);
export const posts = convexTable(
'posts',
{ title: text().notNull(), body: text().notNull(), userId: id('users') },
(t) => [index('by_user').on(t.userId)]
);
export default defineSchema({ users, posts }).relations((r) => ({
users: { posts: r.many.posts() },
posts: { user: r.one.users({ from: r.posts.userId, to: r.users.id }) },
}));
```
}>
Ents auto‑creates edge indexes. In ORM you must add indexes for relation fields (`index('by_user').on(t.userId)`), or relation loading will throw unless `strict: false`.
Context Setup [#context-setup]
```ts title="convex/lib/ctx.ts" showLineNumbers
import { entsTableFactory } from 'convex-ents';
import { entDefinitions } from '../schema';
export function makeCtx(ctx: any) {
const table = entsTableFactory(ctx, entDefinitions);
return { ...ctx, table };
}
```
```ts showLineNumbers title="convex/functions/generated/server.ts"
// Auto-generated by kitcn codegen.
// ctx.orm is available in all cRPC handlers.
```
The CLI generates `convex/functions/generated/server.ts` which wires `createOrm` and attaches `ctx.orm` to all cRPC handlers automatically.
For Date-shaped `createdAt`, add `createdAt: timestamp().notNull().defaultNow()` to your tables. Without an explicit column, system `createdAt` remains `number`.
Query Mapping [#query-mapping]
| Ents | ORM |
| ------------------------------------------ | --------------------------------------------------------------------------------------------------------- |
| `ctx.table('users').get(id)` | `ctx.orm.query.users.findFirst({ where: { id: id } })` |
| `ctx.table('users').getX(id)` | `ctx.orm.query.users.findFirstOrThrow({ where: { id: id } })` |
| `ctx.table('users').get('email', v)` | `ctx.orm.query.users.findFirst({ where: { email: v } })` |
| `ctx.table('users').getMany(ids)` | `ctx.orm.query.users.findMany({ where: { id: { in: ids } }, limit: ids.length })` |
| `ctx.table('users').order('desc').take(5)` | `ctx.orm.query.users.findMany({ orderBy: { createdAt: 'desc' }, limit: 5 })` |
| `ctx.table('users').paginate(opts)` | `ctx.orm.query.users.findMany({ cursor: opts.cursor, limit: opts.numItems })` |
| `ctx.table('users').map(...)` | Prefer `columns` / `extras` (example below). Fall back to `rows.map(...)` when you need custom reshaping. |
| `ctx.table('users').search(...)` | `ctx.orm.query.users.findMany({ search: { index, query, filters? } })` |
Relations & Edge Traversal [#relations--edge-traversal]
```ts showLineNumbers
const user = await ctx.table('users').getX(userId);
const posts = await user.edge('posts').take(5);
```
```ts showLineNumbers {1-4}
const user = await ctx.orm.query.users.findFirstOrThrow({
where: { id: userId },
with: { posts: { limit: 5 } },
});
const posts = user.posts;
```
Projections (columns / extras) [#projections-columns--extras]
```ts showLineNumbers
const recent = await ctx
.table('users')
.order('desc')
.take(5)
.map((u) => ({ id: u.id, name: u.name }));
```
```ts showLineNumbers {2-6}
const recent = await ctx.orm.query.users.findMany({
orderBy: { createdAt: 'desc' },
limit: 5,
columns: { id: true, name: true },
});
```
Many‑to‑Many With Data [#manytomany-with-data]
Ents required two 1:many edges for payloads. ORM always uses explicit join tables:
```ts title="convex/schema.ts" showLineNumbers
const memberships = convexTable('memberships', {
userId: id('users').notNull(),
groupId: id('groups').notNull(),
role: text().notNull(),
});
export default defineSchema({ users, groups, memberships }).relations((r) => ({
users: { memberships: r.many.memberships() },
groups: { memberships: r.many.memberships() },
memberships: {
user: r.one.users({ from: r.memberships.userId, to: r.users.id }),
group: r.one.groups({ from: r.memberships.groupId, to: r.groups.id }),
},
}));
```
}>
One‑way relations are allowed. Define only the sides you plan to query or load.
Mutations [#mutations]
| Ents | ORM |
| ---------------------------------------- | ------------------------------------------------------ |
| `ctx.table('users').insert(data)` | `ctx.orm.insert(users).values(data)` |
| `ctx.table('users').insertMany([...])` | `ctx.orm.insert(users).values([...])` |
| `ctx.table('users').getX(id).patch(p)` | `ctx.orm.update(users).set(p).where(eq(users.id, id))` |
| `ctx.table('users').getX(id).replace(r)` | `ctx.orm.update(users).set(r).where(eq(users.id, id))` |
| `ctx.table('users').getX(id).delete()` | `ctx.orm.delete(users).where(eq(users.id, id))` |
}>
`update().set(...)` is patch‑style. To mimic Ents `replace`, set all fields explicitly (including `null` for nullable columns).
**Many‑to‑many edge mutations** become join‑table inserts/deletes:
```ts showLineNumbers
// Schema: postsTags with index('by_post_tag').on(t.postId, t.tagId)
import { and, eq } from 'kitcn/orm';
// Add edge
await ctx.orm.insert(postsTags).values({ postId, tagId });
// Remove edge
await ctx.orm
.delete(postsTags)
.where(and(eq(postsTags.postId, postId), eq(postsTags.tagId, tagId)));
```
Defaults, Constraints, and Types [#defaults-constraints-and-types]
| Ents | ORM |
| ------------------------------------------- | ------------------------------------------------------------------- |
| `.field('x', v.string(), { default })` | `text().$defaultFn(...)` or `text().default(...)` |
| `.field('x', v.string(), { unique: true })` | `uniqueIndex('by_x').on(t.x)` or `t.x.unique()` |
| `.index('a_b', ['a','b'])` | `index('by_a_b').on(t.a, t.b)` |
| `Ent` / `EntWriter` | `InferSelectModel` / `InferInsertModel` |
Constraints are enforced at runtime by ORM mutations; direct `ctx.db` writes bypass them.
Rules → RLS [#rules--rls]
Ents rules:
```ts showLineNumbers
addEntRules(entDefinitions, {
secrets: { read: (s) => ctx.viewerId === s.userId },
});
```
ORM RLS:
```ts showLineNumbers
const secrets = convexTable.withRLS('secrets', { userId: id('users').notNull() }, (t) => [
rlsPolicy('read_own', { for: 'select', using: (ctx) => eq(t.userId, ctx.viewerId) }),
]);
```
}>
RLS runs at runtime and requires `ctx.orm` to be built with `rls: { ctx }`.\
There is no per‑field write rule hook; enforce invariants in mutations or `check()` constraints.
Deletes & Cascades [#deletes--cascades]
* Ents auto‑cascade deletes based on edge definitions.
* Ents only batches high-volume hard deletes when you opt into `.deletion("scheduled")` on the table.
* ORM equivalent table opt-in is `deletion('scheduled', { delayMs })` in `convexTable(...)` extra config.
* ORM requires explicit `foreignKey(...).onDelete('cascade')` and/or manual cleanup.
* For large fan-out in ORM, use async execution (`execute({ mode: 'async' })` / `executeAsync()`).
* Soft/scheduled deletes are mutation‑time helpers:
```ts showLineNumbers
await ctx.orm.delete(users).where(eq(users.id, userId)).soft();
await ctx.orm
.delete(users)
.where(eq(users.id, userId))
.scheduled({ delayMs: 60_000 });
```
Scheduled hard deletes are cancellation-safe: if `deletionTime` is cleared or changed before the scheduled worker runs, the hard delete is skipped.
Table-default scheduled mode with per-query hard override:
```ts showLineNumbers {7,10,11}
const users = convexTable(
'users',
{
slug: text().notNull(),
deletionTime: integer(),
},
() => [deletion('scheduled', { delayMs: 60_000 })]
);
await ctx.orm.delete(users).where(eq(users.id, userId)).execute(); // scheduled by default
await ctx.orm.delete(users).where(eq(users.id, userId)).hard().execute(); // force hard delete
```
Notes on Ents Gaps [#notes-on-ents-gaps]
The ORM resolves or avoids common Ents pain points:
* **Uniqueness under concurrency**: use `onConflictDoNothing()` / `onConflictDoUpdate()` and retries.
* **Many‑to‑many with data**: explicit join tables with payload fields.
* **Compound unique fields**: `unique()` / `uniqueIndex()` supported.
* **One‑way relations**: supported by defining only the needed side.
* **Edge write methods**: use join‑table mutations instead of `edge().add/remove`.
Next Steps [#next-steps]
# Setup (/docs/nextjs)
import { InfoIcon } from "lucide-react"
Overview [#overview]
Setting up kitcn with Next.js involves these components:
| Component | Description |
| ----------------- | ----------------------------------- |
| Caller factory | Creates server-side callers for RSC |
| Client provider | Auth-aware provider with SSR token |
| API route handler | Better Auth API routes |
| RSC helpers | Prefetching and hydration utilities |
}>
**Prerequisite:** Complete [Auth Server](/docs/auth/server) first to configure your Convex backend.
Installation [#installation]
First, install the required packages:
}>
**Note:** Complete [React Setup](/docs/react) first to create your `query-client.ts` with `hydrationConfig`.
Environment Variables [#environment-variables]
Add these to your `.env.local`:
```bash title=".env.local" showLineNumbers
# WebSocket API (port 3210)
NEXT_PUBLIC_CONVEX_URL=http://localhost:3210
# HTTP routes (port 3211)
NEXT_PUBLIC_CONVEX_SITE_URL=http://localhost:3211
# Better Auth
NEXT_PUBLIC_SITE_URL=http://localhost:3000
```
```bash title=".env.local" showLineNumbers
# Generated by Convex
NEXT_PUBLIC_CONVEX_URL=https://your-project.convex.cloud
# Add manually - replace .cloud with .site
NEXT_PUBLIC_CONVEX_SITE_URL=https://your-project.convex.site
# Better Auth
NEXT_PUBLIC_SITE_URL=http://localhost:3000
```
Caller Factory [#caller-factory]
First, create a caller factory for Better Auth:
```ts title="src/lib/convex/server.ts" showLineNumbers {3,5-9}
import { api } from '@convex/api';
import { convexBetterAuth } from 'kitcn/auth/nextjs';
export const { createContext, createCaller, handler } = convexBetterAuth({
api,
convexSiteUrl: process.env.NEXT_PUBLIC_CONVEX_SITE_URL!,
});
```
See the [API Reference](#convexbetterauth) below for full details on what `convexBetterAuth` returns and its options.
}>
**Note:** Not using Better Auth? See [Server Side Calls](/docs/server/server-side-calls).
Client Provider [#client-provider]
Set up the client provider with Better Auth. The `initialToken` prop enables authenticated SSR:
```tsx title="src/lib/convex/convex-provider.tsx" showLineNumbers {2-3,9-13,19-24}
'use client';
import { ConvexReactClient } from 'convex/react';
import { ConvexAuthProvider } from 'kitcn/auth/client';
import { inferAdditionalFields } from 'better-auth/client/plugins';
import { createAuthClient } from 'better-auth/react';
import { convexClient } from 'kitcn/auth/client';
import type { Auth } from '@convex/auth-shared';
const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!);
const authClient = createAuthClient({
baseURL: process.env.NEXT_PUBLIC_SITE_URL!,
plugins: [inferAdditionalFields(), convexClient()],
});
export function ConvexProvider({
children,
token,
}: {
children: React.ReactNode;
token?: string;
}) {
return (
{children}
);
}
```
Use in your layout RSC to fetch the token server-side:
```tsx title="src/app/(app)/layout.tsx" showLineNumbers {5,7}
import { ConvexProvider } from '@/lib/convex/convex-provider';
import { caller } from '@/lib/convex/rsc';
export async function Layout({ children }: { children: React.ReactNode }) {
const token = await caller.getToken();
return {children};
}
```
This pattern ensures authenticated queries work on the initial SSR render.
API Route Handler [#api-route-handler]
Create the Next.js API route for Better Auth:
```ts title="src/app/api/auth/[...all]/route.ts" showLineNumbers {3}
import { handler } from '@/lib/convex/server';
export const { GET, POST, OPTIONS } = handler;
```
RSC Setup [#rsc-setup]
```tsx title="src/lib/convex/rsc.tsx" showLineNumbers {1,14-15,17-18,21-23,26,29,32-33,36-37,43,50-54}
import 'server-only';
import { api } from '@convex/api';
import type { FetchQueryOptions } from '@tanstack/react-query';
import {
dehydrate,
HydrationBoundary,
QueryClient,
} from '@tanstack/react-query';
import {
createServerCRPCProxy,
getServerQueryClientOptions,
} from 'kitcn/rsc';
import { headers } from 'next/headers';
import { cache } from 'react';
import { hydrationConfig } from './query-client';
import { createCaller, createContext } from './server';
// RSC context factory - wraps createContext with cache() and next/headers
const createRSCContext = cache(async () =>
createContext({ headers: await headers() })
);
// Server caller - direct calls without caching/hydration
export const caller = createCaller(createRSCContext);
// Server-compatible cRPC proxy (queryOptions only)
export const crpc = createServerCRPCProxy({ api });
// Create server QueryClient with HTTP-based fetching
function createServerQueryClient() {
return new QueryClient({
defaultOptions: {
...hydrationConfig, // SuperJSON serialization for SSR
...getServerQueryClientOptions({
getToken: caller.getToken,
convexSiteUrl: process.env.NEXT_PUBLIC_CONVEX_SITE_URL!,
}),
},
});
}
// Cache QueryClient per request
export const getQueryClient = cache(createServerQueryClient);
// Fire-and-forget prefetch for client hydration
export function prefetch(
queryOptions: T
): void {
void getQueryClient().prefetchQuery(queryOptions);
}
// Hydration wrapper for client components
export function HydrateClient({ children }: { children: React.ReactNode }) {
const queryClient = getQueryClient();
const dehydratedState = dehydrate(queryClient);
return (
{children}
);
}
/**
* Preload a query - returns data + hydrates for client.
* Use for server-side data access (metadata, conditionals).
*/
export function preloadQuery<
TQueryFnData = unknown,
TError = Error,
TData = TQueryFnData,
TQueryKey extends readonly unknown[] = readonly unknown[],
>(
options: FetchQueryOptions
): Promise {
return getQueryClient().fetchQuery(options);
}
```
The RSC file above exports these helpers. See the [Helpers](#helpers) reference for descriptions of each.
Next Steps [#next-steps]
API Reference [#api-reference]
convexBetterAuth [#convexbetterauth]
`convexBetterAuth` returns:
| Export | Description |
| --------------- | ----------------------------- |
| `createContext` | Creates RSC context with auth |
| `createCaller` | Creates server-side caller |
| `handler` | Next.js API route handler |
Options:
| Option | Description |
| --------------------- | -------------------------------------------- |
| `api` | Your Convex API object |
| `convexSiteUrl` | Convex site URL (must end in `.convex.site`) |
| `auth.jwtCache` | Enable/disable JWT caching (default: `true`) |
| `auth.isUnauthorized` | Custom UNAUTHORIZED error detection |
Helpers [#helpers]
The RSC Setup exports these helpers (see code above for implementations):
| Helper | Description |
| --------------- | ---------------------------------------------------------------------------------------- |
| `caller` | Direct server calls - **not cached, not hydrated to client**. Use for server-only logic. |
| `prefetch` | Fire-and-forget prefetch. Data fetched in background and hydrated to client. |
| `HydrateClient` | Wrapper that dehydrates prefetched queries for client hydration. |
| `preloadQuery` | Awaited fetch that returns data on the server. Equivalent to Convex's `preloadQuery`. |
Server Proxy [#server-proxy]
`createServerCRPCProxy` creates a cRPC proxy for Server Components:
* Only supports `queryOptions` (no mutations in RSC)
* Generates the same query keys as the client proxy
```ts showLineNumbers {4-6}
import { createServerCRPCProxy } from 'kitcn/rsc';
// Server proxy - queryOptions only, no auth config
const crpc = createServerCRPCProxy({ api });
crpc.posts.list.queryOptions({}); // Works
crpc.posts.create.mutationOptions(); // Not available in RSC
```
Query Client Options [#query-client-options]
`getServerQueryClientOptions` configures the QueryClient for server-side HTTP fetching:
```ts showLineNumbers {5-9,13-15}
import { getServerQueryClientOptions } from 'kitcn/rsc';
// With auth + HTTP routes
const queryClient = new QueryClient({
defaultOptions: {
...getServerQueryClientOptions({
getToken: caller.getToken,
convexSiteUrl: process.env.NEXT_PUBLIC_CONVEX_SITE_URL!,
}),
},
});
// Without auth (public queries only)
const queryClient = new QueryClient({
defaultOptions: {
...getServerQueryClientOptions(),
convexSiteUrl: process.env.NEXT_PUBLIC_CONVEX_SITE_URL!,
},
});
```
`Date` support is always on. If you pass a custom `transformer`, it is additive and should be shared across server, client, and caller boundaries.
# Server Components (/docs/nextjs/rsc)
import { InfoIcon } from "lucide-react"
Basic Usage [#basic-usage]
Prefetch queries in Server Components and wrap client components with `HydrateClient`:
```tsx title="app/posts/page.tsx" showLineNumbers {4,6-8}
import { crpc, HydrateClient, prefetch } from '@/lib/convex/rsc';
import { PostList } from './post-list';
export default async function PostsPage() {
// Prefetch data on server (fire-and-forget)
prefetch(crpc.posts.list.queryOptions({}));
return (
);
}
```
```tsx title="app/posts/post-list.tsx" showLineNumbers {5,8}
'use client';
import { useQuery } from '@tanstack/react-query';
import { crpc } from '@/lib/convex/client';
export function PostList() {
// Instant data from server prefetch, then real-time updates
const { data: posts } = useQuery(crpc.posts.list.queryOptions({}));
return (
{posts?.map((post) => (
{post.title}
))}
);
}
```
Auth-Aware Prefetching [#auth-aware-prefetching]
Use `skipUnauth` to prefetch queries that require authentication without blocking:
```tsx title="app/layout.tsx" showLineNumbers {7}
import { crpc, HydrateClient, prefetch } from '@/lib/convex/rsc';
export default async function AppLayout({
children,
}: {
children: React.ReactNode;
}) {
// Skip if not authenticated, don't error
prefetch(crpc.user.getCurrentUser.queryOptions({}, { skipUnauth: true }));
return {children};
}
```
Multiple Prefetches [#multiple-prefetches]
Prefetch multiple queries in parallel:
```tsx title="app/dashboard/page.tsx" showLineNumbers {5-7}
import { crpc, HydrateClient, prefetch } from '@/lib/convex/rsc';
export default async function DashboardPage() {
// All prefetch in parallel (fire-and-forget)
prefetch(crpc.user.getCurrentUser.queryOptions({}, { skipUnauth: true }));
prefetch(crpc.posts.list.queryOptions({}));
prefetch(crpc.stats.dashboard.queryOptions({}));
return (
);
}
```
caller [#caller]
Direct server calls - **detached from query client, not cached, not hydrated to client**. Like tRPC's `createCaller`, use when you only need server-side access.
```tsx title="app/admin/page.tsx" showLineNumbers {4,6-8}
import { caller } from '@/lib/convex/rsc';
export default async function AdminPage() {
// Direct call - server only, not hydrated
const user = await caller.user.getSessionUser({});
if (!user?.isAdmin) {
redirect('/');
}
// Continue with admin logic...
return ;
}
```
When to use caller [#when-to-use-caller]
| Use Case | Description |
| ------------------------- | -------------------------------------------------------- |
| Server-side conditionals | Redirects, auth checks where data isn't needed on client |
| API routes and middleware | Server-only logic |
| Sensitive data | Data that shouldn't be exposed to client |
See [Server Side Calls](/docs/server/server-side-calls) for setup.
preloadQuery [#preloadquery]
**Equivalent to Convex's `preloadQuery`** - awaited fetch that returns data on the server. Use when you need server-side access to the data:
```tsx title="app/posts/[id]/page.tsx" showLineNumbers {7,9-11}
import { crpc, HydrateClient, preloadQuery } from '@/lib/convex/rsc';
export default async function PostPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
// Await data - available on server
const post = await preloadQuery(crpc.posts.get.queryOptions({ id }));
if (!post) {
notFound();
}
return (
{post.title}
);
}
```
When to use preloadQuery [#when-to-use-preloadquery]
| Use Case | Description |
| ------------------------ | ----------------------------------------- |
| Server-side conditionals | 404 checks, redirects |
| Metadata generation | Generate page metadata from the data |
| Server rendering | Render data directly in Server Components |
Metadata Generation [#metadata-generation]
```tsx title="app/posts/[id]/page.tsx" showLineNumbers {6,8-12}
import { crpc, preloadQuery } from '@/lib/convex/rsc';
export async function generateMetadata({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const post = await preloadQuery(crpc.posts.get.queryOptions({ id }));
return {
title: post?.title ?? 'Post Not Found',
description: post?.excerpt,
};
}
```
Data Ownership and Revalidation [#data-ownership-and-revalidation]
}>
**Important:** From the TanStack Query perspective, treat Server Components as a place to prefetch data, nothing more.
When using `preloadQuery` (fetchQuery), be aware of data ownership:
```tsx showLineNumbers {2,6,8}
// AVOID: Rendering fetched data in both Server and Client components
export default async function PostsPage() {
const posts = await preloadQuery(crpc.posts.list.queryOptions({}));
return (
{/* Server-rendered - React Query can't revalidate this */}
Nr of posts: {posts.length}
{/* Client-rendered - React Query CAN revalidate this */}
);
}
```
The problem: When React Query revalidates on the client, the `PostList` component updates but `Nr of posts: {posts.length}` stays stale - React Query has no way to revalidate the Server Component.
**Best practices:**
| Practice | Reason |
| ---------------------------------------------- | ------------------------------------------------------ |
| Prefer `prefetch` | Let client components own the data |
| Use `preloadQuery` only when needed | For metadata, 404 checks, redirects |
| Don't render fetched data in Server Components | If client components also use it |
| If you must use both | Understand they can get out of sync after revalidation |
See [TanStack Query Advanced SSR Guide](https://tanstack.com/query/latest/docs/framework/react/guides/advanced-ssr#data-ownership-and-revalidation) for more details.
Important Notes [#important-notes]
HydrateClient Placement [#hydrateclient-placement]
`HydrateClient` must wrap all client components that use prefetched queries:
```tsx showLineNumbers {2-6}
// Correct - HydrateClient wraps client component
prefetch(crpc.posts.list.queryOptions({}));
return (
);
```
```tsx showLineNumbers {2-8}
// Wrong - PostList renders before hydration
return (
<>
>
);
```
Next Steps [#next-steps]
API Reference [#api-reference]
Comparison [#comparison]
| Feature | `prefetch` | `caller` | `preloadQuery` |
| ----------------- | ---------------- | ----------------- | -------------------- |
| Convex equivalent | **None** (new) | `fetchQuery` | `preloadQuery` |
| Blocking | No | Yes | Yes |
| Returns data | No (void) | Yes | Yes |
| Client hydration | Yes | **No** | Yes |
| Revalidation safe | Yes | N/A | Requires care |
| Use case | Client-only data | Server-only logic | Server + client data |
How It Works [#how-it-works]
`HydrateClient` dehydrates the prefetched queries so client components receive data instantly:
1. Server prefetches queries into the QueryClient
2. `HydrateClient` serializes the cache via `dehydrate()`
3. `HydrationBoundary` restores data on the client
4. Client components get instant data, then subscribe for real-time updates
Query Key Matching [#query-key-matching]
The server and client proxies generate identical query keys, ensuring prefetched data is found:
```ts showLineNumbers {2,6}
// Server (RSC)
crpc.posts.list.queryOptions({ limit: 10 });
// queryKey: ['convexQuery', funcRef, { limit: 10 }]
// Client (same key)
crpc.posts.list.queryOptions({ limit: 10 });
// queryKey: ['convexQuery', funcRef, { limit: 10 }]
```
# API (/docs/orm/api-reference)
import { InfoIcon } from "lucide-react"
This is the full API surface for the ORM. The goal is to match Drizzle’s relational query builder while adapting to Convex’s document model.
Schema [#schema]
convexTable() [#convextable]
```ts showLineNumbers
convexTable(
name: TName,
columns: TColumns,
extraConfig?: (t) => [
index('by_field').on(t.someField),
uniqueIndex('by_unique').on(t.someField),
check('check_name', gt(t.someField, 0)),
searchIndex('by_text').on(t.title),
vectorIndex('embedding_vec').on(t.embedding).dimensions(1536),
]
): ConvexTable>
```
**Example**
```ts showLineNumbers
const users = convexTable('users', {
name: text().notNull(),
email: text().notNull(),
age: integer(),
});
```
**Auto‑generated fields**
* `id: Id`
* `createdAt: number` (default)
* `createdAt: Date` when your table defines `createdAt: timestamp().notNull().defaultNow()`
* `_creationTime` is internal-only; public ORM APIs always use `createdAt`
* Use `timestamp()` for point-in-time fields and `date()` for calendar-only fields
convexTable.withRLS() [#convextablewithrls]
```ts showLineNumbers
convexTable.withRLS(name, columns, (t) => [
rlsPolicy('policy_name', {
for: 'select',
using: (ctx) => eq(t.ownerId, ctx.viewerId),
}),
])
```
Enables RLS for the table. If RLS is enabled and no permissive policies apply,
access is denied by default.
index() / uniqueIndex() [#index--uniqueindex]
```ts showLineNumbers
import { index, uniqueIndex } from 'kitcn/orm';
const posts = convexTable(
'posts',
{
title: text().notNull(),
authorId: id('users'),
},
(t) => [
index('by_author').on(t.authorId),
uniqueIndex('by_title').on(t.title),
]
);
```
unique() [#unique]
```ts showLineNumbers
import { unique } from 'kitcn/orm';
const users = convexTable(
'users',
{
firstName: text(),
lastName: text(),
},
(t) => [unique('full_name').on(t.firstName, t.lastName)]
);
```
Use `.nullsNotDistinct()` to treat `null` as a value (PG parity).
foreignKey() [#foreignkey]
```ts showLineNumbers
import { foreignKey } from 'kitcn/orm';
const memberships = convexTable(
'memberships',
{
userSlug: text().notNull(),
},
(t) => [foreignKey({ columns: [t.userSlug], foreignColumns: [users.slug] })]
);
```
Use `.onUpdate()` / `.onDelete()` to configure foreign key actions.
Actions are enforced at runtime by ORM mutations.
For `cascade` / `set null` / `set default`, add an index on the referencing columns.
Without an index, the ORM will throw when it detects referencing rows.
check() [#check]
```ts showLineNumbers
import { check, convexTable, gt, integer } from 'kitcn/orm';
const users = convexTable(
'users',
{
age: integer(),
},
(t) => [check('adult_age', gt(t.age, 18))]
);
```
Checks are enforced at runtime by ORM mutations and follow SQL-like NULL semantics (the check fails only when the expression is `false`).
searchIndex() [#searchindex]
```ts showLineNumbers
import { searchIndex } from 'kitcn/orm';
const posts = convexTable(
'posts',
{
title: text().notNull(),
authorId: id('users'),
},
(t) => [searchIndex('by_title').on(t.title).filter(t.authorId)]
);
```
Use `.staged()` to stage a search index for later activation.
vectorIndex() [#vectorindex]
```ts showLineNumbers
import { vector, vectorIndex } from 'kitcn/orm';
const posts = convexTable(
'posts',
{
authorId: id('users'),
embedding: vector(1536).notNull(),
},
(t) => [
vectorIndex('embedding_vec')
.on(t.embedding)
.dimensions(1536)
.filter(t.authorId),
]
);
```
Vector indexes require `.dimensions(n)` and can be staged with `.staged()`.
deletion() [#deletion]
```ts showLineNumbers
import { deletion } from 'kitcn/orm';
const users = convexTable(
'users',
{
slug: text().notNull(),
deletionTime: integer(),
},
() => [deletion('scheduled', { delayMs: 60_000 })]
);
```
Sets table-level default delete mode:
* `deletion('hard')`
* `deletion('soft')`
* `deletion('scheduled', { delayMs? })`
Per-query delete mode methods still take precedence: `.hard()`, `.soft()`, `.scheduled({ delayMs })`.
vector() [#vector]
```ts showLineNumbers
vector(1536)
vector('embedding', 768)
```
discriminator() [#discriminator]
```ts showLineNumbers
discriminator({
as: 'details', // optional alias, defaults to "details"
variants: {
role_change: {
targetUserId: id('users'),
oldRole: text().notNull(),
},
security_alert: {
severity: text().notNull(),
},
},
})
```
Schema-first discriminated union. Variant fields become nullable physical columns; reads synthesize a nested discriminated union at the alias. Writes are flat — the ORM validates variant fields at runtime. One per table. See [Polymorphic Associations](/docs/orm/schema/relations#polymorphic-associations) for the full guide and rules.
arrayOf() / objectOf() / unionOf() [#arrayof--objectof--unionof]
```ts showLineNumbers
arrayOf(text().notNull())
objectOf({
actor: text().notNull(),
metadata: objectOf({
source: text().notNull(),
}),
})
objectOf(unionOf(text().notNull(), integer().notNull()).notNull())
```
custom() / json() [#custom--json]
```ts showLineNumbers
custom(v.object({ type: v.string(), data: v.any() }))
json<{ tags: string[] }>()
```
Advanced escape hatches for arbitrary Convex validator shapes.
Column Modifiers [#column-modifiers]
```ts showLineNumbers
const users = convexTable('users', {
slug: text().notNull(),
});
text().notNull()
text().default('member')
text().$type<'admin' | 'member'>()
integer().$defaultFn(() => Date.now())
integer().$onUpdateFn(() => Date.now())
text().unique()
text().unique('handle_unique', { nulls: 'not distinct' })
id('users').references(() => users.id)
text().references(() => users.slug, { onDelete: 'cascade' })
```
rlsPolicy() [#rlspolicy]
```ts showLineNumbers
rlsPolicy(name, {
as: 'permissive' | 'restrictive',
for: 'all' | 'select' | 'insert' | 'update' | 'delete',
to: 'public' | 'current_user' | RlsRole | string | Array<...>,
using: (ctx, t) => FilterExpression,
withCheck: (ctx, t) => FilterExpression,
})
```
`using` controls read/update/delete visibility. `withCheck` controls insert/update
values. When omitted, `withCheck` falls back to `using`.
rlsRole() [#rlsrole]
```ts showLineNumbers
rlsRole('admin', { createRole: true }).existing();
```
.relations() [#relations]
```ts showLineNumbers
export default defineSchema({ users, posts }).relations((r) => ({
users: { posts: r.many.posts() },
posts: {
author: r.one.users({ from: r.posts.authorId, to: r.users.id }),
},
}))
```
Database [#database]
createOrm() [#createorm]
Creates a unified ORM setup object. The CLI auto-generates this in `convex/functions/generated/server.ts` — you don't need to call it manually.
}>
The generated module wires `schema`, `triggers`, `ormFunctions`, `capabilities`, and `internalMutation`, then exports `scheduledMutationBatch` and `scheduledDelete`. See the generated file for the full setup.
**`capabilities`**
Optional ORM subsystems. Convex bundles everything a module statically imports and has no dynamic
`import()`, so `kitcn/orm` does not reach them on its own. `kitcn codegen` registers the ones the
app uses; a hand-written `createOrm()` passes them itself:
```ts showLineNumbers
import { createOrm } from 'kitcn/orm';
import { aggregateCapability } from 'kitcn/orm/aggregate-index';
import { migrationCapability } from 'kitcn/orm/migrations';
export const orm = createOrm({
schema,
ormFunctions,
capabilities: [aggregateCapability(), migrationCapability()],
})
```
| Capability | Registered when | Required by |
| ----------------------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `aggregateCapability()` | the schema declares an `aggregateIndex` or `rankIndex` | `aggregateIndex`/`rankIndex` schemas, filtered `count()`, `count({ select: { field } })`, `aggregate()`, `groupBy()`, `rank()`, relation `_count`, and `orm.api()` backfill handlers |
| `migrationCapability()` | `/migrations/manifest.ts` exists | `orm.api()` migration handlers |
Every procedure module imports `generated/server.ts`, so `kitcn codegen` registers a capability only
when the app uses it — otherwise the runtime would land in every Convex function of the app.
Unfiltered `count()` and `aggregate({ _count: true })` are served by the native Convex count syscall
and need no capability.
`createdAt` is always public in ORM rows. Its type is:
* `number` for the system alias
* `Date` when your schema defines `createdAt: timestamp().notNull().defaultNow()`
**RLS options**
```ts showLineNumbers
const ormDb = orm.db(ctx, {
rls: {
ctx,
roleResolver: (ctx) => ctx.roles ?? [],
},
vectorSearch: ctx.vectorSearch,
})
```
Use `ctx.orm.skipRules` (or `ormDb.skipRules`) for an explicit bypass of RLS.
Pass `vectorSearch` when you want ORM `findMany({ vectorSearch })` support.
db.system [#dbsystem]
`db.system` exposes raw Convex system-table reads for `_storage` and `_scheduled_functions`.
```ts showLineNumbers
await db.system.get(jobId);
await db.system.query('_scheduled_functions').collect();
await db.system.query('_storage').take(20);
```
**Scope**
* Raw passthrough methods like `get` and `query`
* Not ORM query-builder APIs (`findMany`, `findFirst`)
* Not relation loading (`with`) or ORM-specific helpers/rules
Query Builder [#query-builder]
findMany() [#findmany]
```ts showLineNumbers
await db.query.users.findMany({
where: { role: 'admin' },
orderBy: { createdAt: 'desc' },
limit: 10,
offset: 0,
with: { posts: true },
columns: { name: true },
});
```
**Options**
* `where`: object filter, callback expression (`(table, ops) => ...`), or callback predicate via `ops.predicate(...)`
* `search`: `{ index, query, filters? }` (full‑text search mode; only on tables with search indexes)
* `vectorSearch`: `{ index, vector, limit, includeScore?, filter? }` (vector similarity mode; only on tables with vector indexes)
* `orderBy`: `{ field: 'asc' | 'desc' }`
* `limit`, `offset`
* `cursor`: `string | null` (cursor pagination; first page is `null`)
* `maxScan`: `number` (only valid with cursor pagination; caps scan-fallback paths like predicate `where` and post-filter-only plans)
* `allowFullScan`: opt‑in for unsized non-cursor scans and relation fan-out overrides (not supported with cursor pagination; not used with `search` or `vectorSearch`)
* `with`: relation loading
* `withVariants`: `true` to auto-load all `one()` relations on tables with `discriminator()` columns
* `columns`: post‑fetch projection
* `extras`: post‑fetch computed fields
* Polymorphic tables with `discriminator()` columns automatically synthesize a typed discriminated union on read
**Sizing rules (non-paginated)**
* Provide `limit`, or
* Use cursor pagination (`cursor` + `limit`), or
* Configure `defineSchema(..., { defaults: { defaultLimit } })`, or
* Use `allowFullScan: true` to opt into unbounded collection.
For `with` + `many()` relations, set relation `limit` or `defaults.defaultLimit`; otherwise use `allowFullScan`.
Relation loading also enforces fan-out key caps: if unique relation lookup keys exceed `defaults.relationFanOutMaxKeys` (default `1000`), the query throws unless `allowFullScan` is set.
**Search mode constraints**
* `orderBy` is not allowed (results stay in Convex relevance order)
* function `where` (callback/predicate) is not allowed
* relation-based `where` is not allowed
* object `where` on base table fields is allowed (post-search filter)
* `with` is allowed
**Vector mode constraints**
* `limit` is required inside `vectorSearch` (`1..256`)
* `orderBy` is not allowed
* `cursor` is not allowed
* `maxScan` is not allowed
* `where` is not allowed
* `.withIndex(...)` is not allowed
* `offset` is not allowed
* top-level `limit` is not allowed (use `vectorSearch.limit`)
* `includeScore: true` adds `_score` to each returned row
* `with` is allowed
findMany({ search }) [#findmany-search-]
```ts showLineNumbers
await db.query.posts.findMany({
search: {
index: 'text_search',
query: 'galaxy',
filters: { type: 'news' },
},
cursor: null,
limit: 20,
});
```
findMany({ vectorSearch }) [#findmany-vectorsearch-]
```ts showLineNumbers
await db.query.posts.findMany({
vectorSearch: {
index: 'embedding_vec',
vector: args.embedding,
limit: 10,
includeScore: true,
filter: (q) => q.eq('type', 'news'),
},
with: { author: true },
});
```
findFirst() [#findfirst]
```ts showLineNumbers
await db.query.users.findFirst({ where: { email: 'a@b.com' } });
await db.query.posts.findFirst({
search: { index: 'text_search', query: 'galaxy' },
});
```
findFirstOrThrow() [#findfirstorthrow]
Like `findFirst()`, but throws if no row exists.
```ts showLineNumbers
await db.query.users.findFirstOrThrow({ where: { email: 'a@b.com' } });
await db.query.posts.findFirstOrThrow({
search: { index: 'text_search', query: 'galaxy' },
});
```
findMany({ cursor, limit }) [#findmany-cursor-limit-]
```ts showLineNumbers
await db.query.posts.findMany({
where: { published: true },
orderBy: { createdAt: 'desc' },
cursor: null,
limit: 20,
});
```
select() composition (advanced) [#select-composition-advanced]
```ts showLineNumbers
const page = await db.query.messages
.select()
.union([
{
index: {
name: 'by_from_to',
range: (q) => q.eq('from', me).eq('to', them),
},
},
{
index: {
name: 'by_from_to',
range: (q) => q.eq('from', them).eq('to', me),
},
},
])
.interleaveBy(['createdAt', 'id'])
.filter(async (m) => !m.deletedAt)
.map(async (m) => ({ ...m, preview: m.body.slice(0, 120) }))
.paginate({ cursor: null, limit: 20 });
```
Composition stage output is inferred in order:
```ts showLineNumbers
const page = await db.query.messages
.select()
.map(async (m) => ({ ...m, preview: m.body.slice(0, 120) }))
.filter(async (m) => m.preview.length > 0)
.paginate({ cursor: null, limit: 20 });
page.page[0].preview; // string
```
`flatMap` infers relation-targeted output:
```ts showLineNumbers
const expanded = await db.query.users
.select()
.flatMap('posts', { includeParent: false })
.paginate({ cursor: null, limit: 20 });
expanded.page[0].text; // post field
```
Children are read through an index that leads with the relation's foreign key. A stage `where` rides that index when one extends it — `where: { numLikes: { gt: 10 } }` on `posts` reads the `by_author_likes` (authorId, numLikes) range instead of every post by the author. Children then arrive in that index's order, so a lowered range field orders them ahead of creation time. Declare an index whose fields are `(...foreign key, ...filtered fields)` for the stage filters you page on.
Use `select()` when you need pre-pagination transforms (`filter`, `map`, `distinct`, relation `flatMap`) or SQL-like unions/interleaving.
findMany({ pageByKey }) (advanced) [#findmany-pagebykey--advanced]
```ts showLineNumbers
const page = await db.query.messages.findMany({
pageByKey: {
index: 'by_channel',
order: 'asc',
startKey: previousEndKey,
targetMaxRows: 100,
},
});
```
`pageByKey` returns:
```ts showLineNumbers
{
page: T[];
indexKeys: (Value | undefined)[][];
hasMore: boolean;
}
```
Use this mode when you need deterministic key boundaries (explicit adjacency control) instead of opaque cursor tokens.
Query Filters (object where) [#query-filters-object-where]
**Core operators**
* `eq`, `ne`, `gt`, `gte`, `lt`, `lte`
* `between`, `notBetween`
* `in`, `notIn`
* `isNull`, `isNotNull`
* `AND`, `OR`, `NOT`
**String operators (post‑fetch)**
* `like`, `ilike`, `notLike`, `notIlike`
* `startsWith`, `endsWith`, `contains`
**Not supported (SQL subquery operators)**
* `exists`, `notExists` (require SQL subqueries, which Convex does not support at runtime)
* Use relation existence filters instead (`where: { posts: true }` or `where: { NOT: { posts: true } }`)
**Index compilation**
* Index-backed by default when possible.
* Currently index-compiled: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `between`, `notBetween`, `in`, `notIn`, `isNull`, `isNotNull`, `startsWith`, `like('prefix%')`, and same-field equality `OR`.
* Object/callback expression filters can run without `.withIndex(...)`; if not index-compiled, they run as bounded scans.
* Post-fetch operators are typed to require explicit `.withIndex(...)` (`contains`, `endsWith`, `ilike`, `notLike`, `notIlike`, array operators, `NOT`, `RAW`).
**Workarounds for remaining full-scan operators**
* `arrayContains` / `arrayContained` / `arrayOverlaps`: use an inverted/join table indexed by element.
* `contains`: use a search index or tokenized denormalized field.
* `endsWith`: store and index a reversed-string column, then use `startsWith`.
* `ilike` / `notIlike`: normalize to lowercase in a dedicated indexed field.
* `notLike`: index a positive pre-filter, then post-filter with `notLike`.
* `RAW`: narrow with indexed pre-filters and call `.withIndex(...)`.
* Predicate `where`: call `.withIndex(name, range?)` and use `maxScan` for bounded scans (cursor pagination only).
Mutations [#mutations]
insert() [#insert]
```ts showLineNumbers
await db.insert(users).values({ name: 'Ada', email: 'ada@example.com' });
```
update() [#update]
```ts showLineNumbers
await db
.update(users)
.set({ name: 'Ada Lovelace' })
.where(eq(users.id, userId));
```
**`set()` semantics**
* `undefined` values passed to `.set(...)` are ignored (no-op).
* To remove a top-level field, use `unsetToken`: `.set({ field: unsetToken })`.
**Full-scan guardrail**
* `update()` without `where()` throws unless you call `.allowFullScan()`.
* Non‑`id` filters require `.allowFullScan()` only when no index is available.
* Matched rows are capped by `mutationMaxRows` (default `10000`), collected in `mutationBatchSize` pages (default `400`).
* Async fan-out uses `mutationLeafBatchSize` (default `1600`) for non-recursive FK actions.
* Async fan-out enforces `mutationMaxBytesPerBatch` (default `2_097_152` measured bytes) and `mutationScheduleCallCap` (default `800`).
* Override defaults via `defineSchema(..., { defaults: { mutationMaxRows, mutationBatchSize, mutationLeafBatchSize, mutationMaxBytesPerBatch, mutationScheduleCallCap, mutationExecutionMode, mutationAsyncDelayMs } })`.
* For large workloads, use `.paginate({ cursor, limit })` to execute one batch per mutation call.
* Async mode is the default — first batch runs inline, remaining batches are scheduled automatically. Customize with `execute({ batchSize?, delayMs? })`.
* Use `execute({ mode: 'sync' })` to force all rows in a single transaction (throws if exceeding `mutationMaxRows`).
* Async mode cannot be combined with `.paginate()`.
* Paged mode returns `{ continueCursor, isDone, numAffected }` (+ `page` when using `.returning()`).
* Async `execute()` returns the same shape as sync `execute()` (`void` or first-batch `returning()` rows).
* Paged mode currently supports single-range index plans only.
```ts showLineNumbers
await db
.update(users)
.set({ name: 'Ada Lovelace' })
.where(eq(users.email, email));
// If email is not indexed:
// .allowFullScan();
```
delete() [#delete]
```ts showLineNumbers
await db.delete(users).where(eq(users.id, userId));
```
**Full-scan guardrail**
* `delete()` without `where()` throws unless you call `.allowFullScan()`.
* Non‑`id` filters require `.allowFullScan()` only when no index is available.
* Matched rows are capped by `mutationMaxRows` (default `10000`), collected in `mutationBatchSize` pages (default `400`).
* Async fan-out uses `mutationLeafBatchSize` (default `1600`) for non-recursive FK actions.
* Async fan-out enforces `mutationMaxBytesPerBatch` (default `2_097_152` measured bytes) and `mutationScheduleCallCap` (default `800`).
* Override defaults via `defineSchema(..., { defaults: { mutationMaxRows, mutationBatchSize, mutationLeafBatchSize, mutationMaxBytesPerBatch, mutationScheduleCallCap, mutationExecutionMode, mutationAsyncDelayMs } })`.
* For large workloads, use `.paginate({ cursor, limit })` to execute one batch per mutation call.
* Async mode is the default — first batch runs inline, remaining batches are scheduled automatically. Customize with `execute({ batchSize?, delayMs? })`.
* Use `execute({ mode: 'sync' })` to force all rows in a single transaction.
* Async mode cannot be combined with `.paginate()` or resolved scheduled delete mode (`.scheduled(...)` or table-level `deletion('scheduled')`).
* Paged mode returns `{ continueCursor, isDone, numAffected }` (+ `page` when using `.returning()`).
* Async `execute()` returns the same shape as sync `execute()` (`void` or first-batch `returning()` rows).
* Paged mode currently supports single-range index plans only.
```ts showLineNumbers
await db
.delete(users)
.where(eq(users.email, email));
// If email is not indexed:
// .allowFullScan();
```
**Delete helpers**
```ts showLineNumbers
// Soft delete (sets deletionTime)
await db.delete(users).where(eq(users.id, userId)).soft();
// Force hard delete even if table default is soft/scheduled
await db.delete(users).where(eq(users.id, userId)).hard();
// Cascade behavior for FK actions
await db.delete(users).where(eq(users.id, userId)).cascade({ mode: 'hard' });
// Scheduled delete (soft now, hard later)
await db
.delete(users)
.where(eq(users.id, userId))
.scheduled({ delayMs: 60_000 });
```
Scheduled deletes require passing `{ scheduler, scheduledDelete }` when building `ctx.orm`
and exposing `scheduledDeleteFactory(schema, edges, scheduledMutationBatchRef)` as an internal mutation.
The scheduled worker validates the original `deletionTime` token before hard-deleting, so clearing/changing `deletionTime` cancels a pending scheduled delete.
Async mutation batches are wired automatically by codegen in `convex/functions/generated/server.ts`.
returning() [#returning]
```ts showLineNumbers
const [user] = await db
.insert(users)
.values({ name: 'Ada', email: 'ada@example.com' })
.returning({ id: users.id, email: users.email });
```
onConflictDoUpdate() [#onconflictdoupdate]
```ts showLineNumbers
await db
.insert(users)
.values({ email: 'ada@example.com', name: 'Ada' })
.onConflictDoUpdate({
target: users.email,
set: { name: 'Ada Lovelace' },
});
```
If the conflict target has no index, call `.allowFullScan()` to opt in:
```ts showLineNumbers
await db
.insert(users)
.values({ email: 'ada@example.com', name: 'Ada' })
.onConflictDoUpdate({ target: users.email, set: { name: 'Ada' } })
.allowFullScan();
```
Filter Expression Helpers [#filter-expression-helpers]
Use these in mutation `where(...)` clauses:
```ts showLineNumbers
eq(field, value)
ne(field, value)
gt(field, value)
gte(field, value)
lt(field, value)
lte(field, value)
between(field, min, max)
notBetween(field, min, max)
inArray(field, values)
notInArray(field, values)
and(...filters)
or(...filters)
not(filter)
isNull(field)
isNotNull(field)
```
`between` is inclusive of endpoints.
`notBetween` matches values strictly outside the range.
Type Helpers [#type-helpers]
```ts showLineNumbers
InferSelectModel
InferInsertModel
users.$inferSelect
users.$inferInsert
```
Introspection Helpers [#introspection-helpers]
```ts showLineNumbers
getTableColumns(users) // includes id/createdAt
getTableConfig(users) // indexes/unique/fks/rls/checks
```
Notes [#notes]
* `columns` projection is post‑fetch
* String operators are post‑fetch
* `extras` are computed post‑fetch
* `findMany({ cursor, limit })` uses the first `orderBy` field for cursor ordering
Next Steps [#next-steps]
# Overview (/docs/orm)
import { CheckCircle } from "lucide-react"
The ORM mirrors Drizzle's relational query builder while adapting to Convex's document model.
What You Get [#what-you-get]
* Drizzle-style schema and relations (`convexTable`, `.relations(...)`, `one`, `many`)
* Type-safe queries (`findMany`, `findFirst`, cursor pagination, `where`, `orderBy`, `with`)
* Mutations (`insert`, `update`, `delete`, `returning`, `onConflictDoUpdate`)
* Runtime RLS policies (`rlsPolicy`, `convexTable.withRLS`)
* End-to-end TypeScript inference (`InferSelectModel`, `$inferSelect`)
`ctx.orm` is attached once during setup (see [Quickstart](/docs/quickstart#orm-setup)). All examples below assume `ctx.orm` is available.
Where It Differs From SQL ORMs [#where-it-differs-from-sql-orms]
* `id` and `createdAt` are auto-created by Convex
* System `createdAt` is `number`; define `createdAt: timestamp().notNull().defaultNow()` for `Date`
* `timestamp()` is the recommended point-in-time type for audit fields like `createdAt` / `updatedAt`
* `date()` is calendar-only (`YYYY-MM-DD`) and should be used for date-without-time fields
* Fields are nullable by default (`text()` -> `string | null`)
* `columns` projection and string operators run post-fetch
* `with` is relation loading (no SQL joins)
* Cursor pagination needs an index on the primary `orderBy` field
Timestamps And Dates [#timestamps-and-dates]
The ORM keeps Convex storage constraints and gives you a stronger public type API:
* Public system fields are `id` and `createdAt` in ORM APIs.
* Internal Convex fields (`_id`, `_creationTime`) stay internal-only in ORM APIs.
* `timestamp()` gives point-in-time semantics and returns `Date` by default.
* `date()` gives calendar-date semantics and can return `string` or `Date` depending on mode.
For most tables, define an explicit timestamp column:
```ts title="convex/schema.ts" showLineNumbers
import { convexTable, text, timestamp } from 'kitcn/orm';
export const posts = convexTable('posts', {
title: text().notNull(),
createdAt: timestamp().notNull().defaultNow(), // Date in ORM API
});
```
With cRPC, Date wire serialization is built in, so `Date` values round-trip without manual `getTime()` calls.
Quick Example [#quick-example]
```ts title="convex/schema.ts" showLineNumbers {3-7,10-14}
import {
boolean,
convexTable,
defineSchema,
id,
index,
integer,
text,
} from 'kitcn/orm';
export const users = convexTable('users', {
name: text().notNull(),
email: text().notNull(),
role: text(),
age: integer(),
});
export const posts = convexTable('posts', {
title: text().notNull(),
content: text().notNull(),
published: boolean(),
userId: id('users'),
}, (t) => [index('by_published').on(t.published)]);
const tables = { users, posts };
export default defineSchema(tables).relations((r) => ({
users: {
posts: r.many.posts(),
},
posts: {
author: r.one.users({ from: r.posts.userId, to: r.users.id }),
},
}));
```
Query published posts with their authors:
```ts title="convex/functions/posts.ts" showLineNumbers {3-7}
import { publicQuery } from '../lib/crpc';
export const getRecentPosts = publicQuery.query(async ({ ctx }) => {
return ctx.orm.query.posts.findMany({
where: { published: true },
orderBy: { createdAt: 'desc' },
limit: 10,
with: { author: true },
});
});
```
Next Steps [#next-steps]
Tables, relations, and indexes
Filters, ordering, and pagination
Insert, update, and delete operations
Online data migrations with Drizzle-like DX
Full API surface
# LLMs Index (/docs/orm/llms-index)
ORM Documentation Index [#orm-documentation-index]
This file provides a structured index of the ORM documentation for AI assistants and code completion tools.
Core Concepts [#core-concepts]
**Getting Started:**
* `/docs/orm` - Overview, installation, and value proposition
* `/docs/quickstart#orm-setup` - ORM setup section in the main quickstart
**Schema Definition:**
* `/docs/orm/schema` - Table definitions, field types, indexes, and type inference
* `/docs/orm/schema/column-types` - Column builders and TypeScript type mapping
* `/docs/orm/schema/indexes-constraints` - Indexes, unique constraints, and foreign keys
**Relations:**
* `/docs/orm/schema/relations` - One‑to‑one, one‑to‑many, many‑to‑many relations, polymorphic associations
**Querying Data:**
* `/docs/orm/queries` - findMany(), findFirst(), cursor pagination (`cursor` + `limit`), filters, orderBy
* `/docs/orm/queries/operators` - All supported `where` operators (query + mutation)
**Mutations:**
* `/docs/orm/mutations` - insert(), update(), delete(), returning(), onConflictDoUpdate()
* `/docs/orm/mutations/insert` - insert() builder details
* `/docs/orm/mutations/update` - update() builder details
* `/docs/orm/mutations/delete` - delete() builder details
**Row-Level Security:**
* `/docs/orm/rls` - rlsPolicy, rlsRole, and runtime enforcement
Data Migrations [#data-migrations]
* `/docs/orm/migrations` - Built-in online data migrations with `defineMigration`, CLI commands (`create`, `up`, `down`, `status`, `cancel`), deploy integration, and drift safety
Migration & Comparison [#migration--comparison]
* `/docs/orm/migrate-from-convex` - Native Convex (`ctx.db`) → ORM migration guide
* `/docs/orm/migrate-from-ents` - Convex Ents → ORM migration guide
* `/docs/comparison/drizzle` - Drizzle v1 mapping and migration guidance
Reference [#reference]
* `/docs/orm/api-reference` - Full API surface and TypeScript helpers
* `/docs/comparison/drizzle` - Differences, limitations, and performance guidance
Quick Reference [#quick-reference]
Key APIs [#key-apis]
**Schema:**
```ts
convexTable(name, columns)
discriminator({ variants, as? }) // schema-first discriminated union column
defineSchema(tables, { defaults?: { defaultLimit?, mutationBatchSize?, mutationMaxRows? } })
defineRelations(schema, callback)
extractRelationsConfig(schema)
```
**Queries:**
```ts
await ctx.orm.query.table.findMany({
where: { field: value },
orderBy: { createdAt: "desc" },
limit: 10,
offset: 0,
});
await ctx.orm.query.table.findFirst({
where: { field: value },
});
await ctx.orm.query.table.findMany({
where: { active: true },
cursor: null,
limit: 20,
});
await ctx.orm.query.table.withIndex("by_status").findMany({
// Predicate where requires an explicit index plan (no allowFullScan fallback)
where: (_table, { predicate }) => predicate((row) => row.status === "active"),
cursor: null,
limit: 20,
maxScan: 2000,
});
```
**Mutations:**
```ts
await ctx.orm.insert(table).values(data);
await ctx.orm.update(table).set(data).where(eq(table.id, id));
await ctx.orm.delete(table).where(eq(table.id, id));
// Full-scan opt-in (only if no index on email)
await ctx.orm.update(table).set(data).where(eq(table.email, email)); // indexed
await ctx.orm
.update(table)
.set(data)
.where(eq(table.email, email))
.allowFullScan();
await ctx.orm.delete(table).where(eq(table.email, email)); // indexed
await ctx.orm.delete(table).where(eq(table.email, email)).allowFullScan();
```
**RLS:**
```ts
const secret = convexTable.withRLS(
"secrets",
{
/* ... */
},
(t) => [
rlsPolicy("read_own", {
for: "select",
using: (ctx) => eq(t.ownerId, ctx.viewerId),
}),
]
);
```
**Object `where` operators:**
```ts
{ field: value }
{ field: { ne: value } }
{ field: { gt: value } }
{ field: { gte: value } }
{ field: { lt: value } }
{ field: { lte: value } }
{ field: { between: [min, max] } }
{ field: { notBetween: [min, max] } }
{ field: { in: [a, b] } }
{ field: { notIn: [a, b] } }
{ field: { isNull: true } }
{ field: { isNotNull: true } }
{ AND: [ ... ] }
{ OR: [ ... ] }
{ NOT: { ... } }
```
**Mutation filter helpers:**
```ts
eq(field, value);
ne(field, value);
gt(field, value);
gte(field, value);
lt(field, value);
lte(field, value);
between(field, min, max);
notBetween(field, min, max);
inArray(field, values);
notInArray(field, values);
and(...filters);
or(...filters);
not(filter);
isNull(field);
isNotNull(field);
```
Feature Overview [#feature-overview]
**Core features:**
* Schema definition (convexTable, column builders)
* Relations definition and loading (one, many, with)
* Query operations (findMany, findFirst, cursor pagination)
* Count operations (`db.query.*.count()`, windowed `count({ orderBy, skip, take, cursor })`)
* Prisma-style aggregate operations (`db.query.*.aggregate({ _count/_sum/_avg/_min/_max })`)
* GroupBy operations (`db.query.*.groupBy({ by, _count/_sum/_avg/_min/_max })`)
* Where filtering (object filters)
* Pagination (limit, offset)
* Order by (multi‑field, index‑aware first sort)
* Type inference
* Column selection (post‑fetch)
* String operators (post‑fetch)
* Mutations (insert, update, delete, returning)
* Schema-first polymorphic unions via `discriminator({ variants, as? })` in `convexTable(...)` (reads synthesize typed discriminated union at alias; no query config needed)
* Distinct via select pipeline (`.select().distinct({ fields: ['field'] })`)
* Ranked access via `/docs/orm/queries/aggregates` (`rankIndex` + `rank()` for leaderboards, random access, sorted pagination)
**Unavailable in Convex:**
* Raw SQL queries
* SQL joins
* SQL `having` and SQL window functions (`groupBy` is supported with finite constraints)
Error Messages & Solutions [#error-messages--solutions]
* `where is not a function` → Use object form: `where: { field: value }`
* `Property 'query' does not exist` → Ensure ORM is attached as `ctx.orm`
* `Type error: missing required field` → Check `.notNull()` in schema
* `findUnique is not a function` → Use `findFirst` with `where`
* `COUNT_NOT_INDEXED` → declare matching `aggregateIndex(...).on(...)` and run backfill until READY
* `COUNT_FILTER_UNSUPPORTED` → count supports `eq`/`in`/`isNull`/range + `AND`, plus finite safe `OR` rewrite in v1
* `COUNT_INDEX_BUILDING` → run `aggregateBackfill`, then check `aggregateBackfillStatus` until READY
* `aggregateBackfill` says rebuild required (or CLI tells you to run rebuild) → key shape changed; run `kitcn aggregate rebuild`
* `COUNT_RLS_UNSUPPORTED` → count is disabled in RLS-restricted contexts in v1
* `AGGREGATE_NOT_INDEXED` → declare matching `aggregateIndex(...).on(...)` metric coverage and run backfill until READY
* `AGGREGATE_FILTER_UNSUPPORTED` → `aggregate(...)` supports `eq`/`in`/`isNull`/range + `AND`, plus finite safe `OR` rewrite in v1
* `AGGREGATE_INDEX_BUILDING` → run `aggregateBackfill` and wait for `READY`
* `AGGREGATE_RLS_UNSUPPORTED` → `aggregate(...)` is disabled in RLS-restricted contexts in v1
* `'include' does not exist` → Use `with` instead of `include`
* `findMany() requires explicit sizing` → Add `limit`, use cursor pagination (`cursor` + `limit`), set schema `defaultLimit`, or opt in with `allowFullScan`
* `.withIndex(...) required` → `predicate` `where` and typed post-fetch operators need explicit index selection
* `matched more than mutationMaxRows` → Narrow update/delete filter or raise `defaults.mutationMaxRows`
* `update/delete pagination does not support multi-probe filters yet` → Rewrite to a single-range index filter, or run non-paginated mode with row cap
**Index-compiled operators (when indexed):**
* `eq`, `ne`, `gt`, `gte`, `lt`, `lte`
* `between`, `notBetween`
* `in`, `notIn`
* `isNull`, `isNotNull`
* `startsWith`
* `like('prefix%')`
* same-field equality `OR` branches
**Post-fetch operators (typed API requires explicit `.withIndex(...)`):**
* `arrayContains`, `arrayContained`, `arrayOverlaps` (use inverted/join tables)
* `contains` (use search index or tokenized denormalized field)
* `endsWith` (use reversed-string indexed column + `startsWith`)
* `ilike`, `notIlike` (use normalized lowercase indexed field)
* `notLike` (use indexed pre-filter then post-filter)
* predicate `where` and `RAW` (narrow with indexed pre-filters first)
# Migrations (/docs/orm/migrations)
import { InfoIcon } from "lucide-react"
In this guide, we'll walk through built-in data migrations for kitcn. You'll learn when to use them, how to author migration files, and how they integrate with `kitcn dev` and `kitcn deploy` for safe, auditable schema evolution.
When to Migrate [#when-to-migrate]
Convex is not SQL — you don't need migrations for every schema change. Most changes are backward-compatible and can ship without touching existing documents.
**Skip migrations when changes are backward-compatible:**
* Adding optional fields (existing docs simply don't have the field)
* Adding new tables or indexes
* Adding code-level defaults on read (`doc.priority ?? 'medium'`)
* Keeping old fields around (deprecated) while new code rolls out
**Use migrations when existing documents must be rewritten:**
* Optional → required (e.g. `todos.priority` must exist on every row)
* Type or enum narrowing (`string` → enum, removing enum values)
* Field rename or removal where old docs would violate schema or break code
* Semantic rewrites or backfills you need done once and audited
}>
**Rule of thumb:** If old documents still pass your schema and app logic, no migration. If old documents would fail schema validation or break behavior, run a migration.
Creating a Migration [#creating-a-migration]
We'll start by scaffolding a new migration file. The CLI generates a timestamped file and updates the manifest:
```bash showLineNumbers
npx kitcn migrate create backfill_todo_priority
```
This creates two files:
* `convex/functions/migrations/_backfill_todo_priority.ts` — your migration logic
* `convex/functions/migrations/manifest.ts` — auto-generated ordered list of all migrations
Now let's open the generated file and implement the migration:
```ts showLineNumbers title="convex/functions/migrations/20260227_080239_backfill_todo_priority.ts" {5-10}
import { defineMigration } from 'kitcn/orm';
export const migration = defineMigration({
id: '20260227_080239_backfill_todo_priority',
description: 'backfill todo priority to medium for existing rows',
up: {
table: 'todos',
migrateOne: async (_ctx, doc) => {
if (doc.priority === undefined || doc.priority === null) {
return { priority: 'medium' };
}
},
},
});
```
Each migration has an `id` (timestamped, unique), a `table` to iterate over, and a `migrateOne` callback. Return a partial object to patch the document, or return nothing to skip it.
Adding a Down Migration [#adding-a-down-migration]
If you want rollback support, add a `down` step:
```ts showLineNumbers title="convex/functions/migrations/20260227_080239_backfill_todo_priority.ts" {12-20}
import { defineMigration } from 'kitcn/orm';
export const migration = defineMigration({
id: '20260227_080239_backfill_todo_priority',
description: 'backfill todo priority to medium for existing rows',
up: {
table: 'todos',
migrateOne: async (_ctx, doc) => {
if (doc.priority === undefined || doc.priority === null) {
return { priority: 'medium' };
}
},
},
down: {
table: 'todos',
migrateOne: async (_ctx, doc) => {
// Keep down handlers explicit and safe for your data model.
// Do not blindly unset values unless that's really what you want.
if (doc.priority === 'medium') {
return { priority: undefined };
}
},
},
});
```
`down` is optional and always explicit. `kitcn dev` and `kitcn deploy` only auto-run `up` migrations. If you try to roll back a migration without a `down` handler, the CLI will error.
}>
Not every migration should have a `down`. If the transform is not safely reversible, leave `down` undefined and treat rollback as a forward fix in a new migration.
Running Migrations [#running-migrations]
Apply Pending Migrations [#apply-pending-migrations]
```bash showLineNumbers
# Local
npx kitcn migrate up
# Production
npx kitcn migrate up --prod
```
This applies all pending migrations in order. Already-applied migrations are skipped.
Roll Back [#roll-back]
```bash showLineNumbers {2,5}
# Roll back the latest migration
npx kitcn migrate down --steps 1 --prod
# Roll back everything after a specific migration
npx kitcn migrate down --to 20260227_080239_backfill_todo_priority --prod
```
Check Status [#check-status]
```bash showLineNumbers
npx kitcn migrate status --prod
```
Shows applied/pending migrations, active runs, and any drift issues.
Cancel Active Run [#cancel-active-run]
```bash showLineNumbers
npx kitcn migrate cancel --prod
```
Cancels a currently running migration. Useful if a migration is taking too long or you need to deploy a fix.
Deploy Integration [#deploy-integration]
`kitcn deploy` orchestrates the full lifecycle automatically:
1. `convex deploy` — push code
2. `migrate up` — apply pending migrations
3. `aggregate backfill` — rebuild aggregate indexes
By default, deploy is **strict** — it blocks on migration drift and waits for completion. You can configure this behavior per environment:
```json title="kitcn.json" showLineNumbers {3-11}
{
"deploy": {
"migrations": {
"enabled": "auto",
"wait": true,
"batchSize": 256,
"pollIntervalMs": 1000,
"timeoutMs": 900000,
"strict": true,
"allowDrift": false
}
}
}
```
`kitcn dev` also supports auto-migration with more relaxed defaults (`strict: false`, `allowDrift: true`). See [CLI Backend](/docs/cli/backend) for the full configuration reference.
Drift Safety [#drift-safety]
Migrations track checksums based on their metadata and function source. Two drift checks are enforced automatically:
| Drift Type | What Happened | Behavior |
| ------------------------- | -------------------------------------------------- | ------------------- |
| **Checksum mismatch** | An already-applied migration file was edited | Blocks the next run |
| **Missing from manifest** | An applied migration was deleted from the manifest | Blocks the next run |
```bash showLineNumbers
❌ Applied migration '20260227_080239_backfill_todo_priority' checksum drift detected.
```
This is intentional — applied migrations are immutable history. If you need follow-up behavior, create a new migration with a new id.
}>
`allowDrift` exists for emergency overrides only. In production, leave it `false`.
End-to-End Example: Optional → Required [#end-to-end-example-optional--required]
Let's walk through the most common migration workflow — hardening an optional field to required.
**Step 1:** Create the migration:
```bash showLineNumbers
npx kitcn migrate create backfill_todo_priority
```
**Step 2:** Implement the backfill (fill in missing values):
```ts showLineNumbers {6-10}
export const migration = defineMigration({
id: '20260227_080239_backfill_todo_priority',
up: {
table: 'todos',
migrateOne: async (_ctx, doc) => {
if (doc.priority === undefined || doc.priority === null) {
return { priority: 'medium' };
}
},
},
});
```
**Step 3:** Run codegen and apply:
```bash showLineNumbers
npx kitcn codegen
npx kitcn migrate up
```
**Step 4:** Harden the schema now that all rows have the field:
```ts showLineNumbers {6}
const todos = convexTable('todos', {
title: text().notNull(),
completed: boolean().notNull(),
userId: id('user').notNull(),
projectId: id('project'),
priority: textEnum(['low', 'medium', 'high'] as const).notNull(),
});
```
**Step 5:** Run codegen again to confirm the schema compiles. Done — every document satisfies the new constraint.
Best Practices [#best-practices]
Keep Migrations Small and Focused [#keep-migrations-small-and-focused]
One migration per schema change. Don't bundle unrelated backfills — they can't be rolled back independently.
Always Run Codegen Before Migration Up [#always-run-codegen-before-migration-up]
Migration code must be deployed (or pushed via codegen) before running `migrate up`. If you race `codegen` and `migrate up`, the migration may use stale function code.
Deterministic order: `dev` or `codegen` first, then `migrate up`.
Use safe_bypass for Performance [#use-safe_bypass-for-performance]
By default, migrations run with `writeMode: 'safe_bypass'` — they bypass ORM rules and lifecycle triggers for speed and isolation. If your migration needs normal hook/rule behavior, set `writeMode: 'normal'`:
```ts showLineNumbers {4}
up: {
table: 'todos',
writeMode: 'normal',
migrateOne: async (_ctx, doc) => {
return { priority: 'medium' };
},
},
```
Don't Edit Applied Migrations [#dont-edit-applied-migrations]
Treat applied migration files like committed history. Editing them triggers checksum drift. Create a new migration instead.
Prefer Code Defaults Over Migrations [#prefer-code-defaults-over-migrations]
Before reaching for a migration, ask: can I handle this in code?
```ts showLineNumbers
// ✅ Code default — no migration needed
const priority = doc.priority ?? 'medium';
// ❌ Migration for something code can handle
```
API Reference [#api-reference]
defineMigration() [#definemigration]
```ts showLineNumbers
import { defineMigration } from 'kitcn/orm';
defineMigration({
id: string, // Unique timestamped identifier
description?: string, // Human-readable description
up: MigrationStep, // Forward migration
down?: MigrationStep, // Rollback migration (optional)
});
```
MigrationStep [#migrationstep]
| Field | Type | Default | Description |
| ------------ | ----------------------------- | ---------------------- | ---------------------------------------------- |
| `table` | `string` | — | Table to iterate over |
| `migrateOne` | `(ctx, doc) => patch \| void` | — | Transform callback per document |
| `batchSize` | `number` | `128` runtime fallback | Documents per batch |
| `writeMode` | `'safe_bypass' \| 'normal'` | `'safe_bypass'` | Bypass or run ORM rules and lifecycle triggers |
`migrate up/down` from CLI passes a default batch size from config (`256` unless overridden), so CLI runs will usually use `256` unless you set another value.
migrateOne Context [#migrateone-context]
The `ctx` argument in `migrateOne` provides:
| Field | Type | Description |
| ------------- | --------------------------- | --------------------------------- |
| `db` | `DatabaseWriter` | Raw Convex database writer |
| `orm` | `OrmWriter` | ORM writer for complex operations |
| `migrationId` | `string` | Current migration id |
| `runId` | `string` | Current run id |
| `direction` | `'up' \| 'down'` | Current direction |
| `dryRun` | `boolean` | Whether this is a dry run |
| `writeMode` | `'safe_bypass' \| 'normal'` | Current write mode |
defineMigrationSet() [#definemigrationset]
Used internally by the generated manifest. Collects migrations, computes checksums, and sorts by id:
```ts showLineNumbers
import { defineMigrationSet } from 'kitcn/orm';
export const migrations = defineMigrationSet([
migration1,
migration2,
]);
```
CLI Commands [#cli-commands]
| Command | Description |
| --------------------------------- | ------------------------------------------------ |
| `migrate create ` | Scaffold timestamped migration + update manifest |
| `migrate up [--prod]` | Apply all pending migrations |
| `migrate down --steps N [--prod]` | Roll back N migrations |
| `migrate down --to [--prod]` | Roll back to a specific migration |
| `migrate status [--prod]` | Show applied/pending state |
| `migrate cancel [--prod]` | Cancel active run |
Internal Tables [#internal-tables]
Migrations use two internal storage tables injected by `defineSchema`:
* `migration_state` — tracks applied checksums and progress per migration
* `migration_run` — tracks run lifecycle (start, status, failures)
These are reserved table names. Do not create tables with these names.
Status Procedure [#status-procedure]
`generated/server:migrationStatus` is an internal **query**, so reading status
never takes a write transaction and can back a live subscription.
| Arg | Description |
| ------- | ------------------------------------------------------------------------------------------------------------------------------------- |
| `limit` | Number of most recent runs to list. Defaults to `25`, capped at `MAX_STATUS_RUN_LIMIT` (`100`). Higher values are clamped to the cap. |
| `runId` | Return only this run. Ignores `limit`. |
`activeRun` is always resolved independently of `runId`, so a status call
filtered to one run still reports whether any run is in flight. The
`migrations`, `pending`, and `drift` fields always cover every authored
migration and are not affected by `limit`.
Import the cap when you need to assert against it:
```ts showLineNumbers
import { MAX_STATUS_RUN_LIMIT } from 'kitcn/orm/migrations';
```
Runtime Statuses [#runtime-statuses]
| Status | Meaning |
| --------------- | --------------------------------------------- |
| `pending` | Migration state is queued, not yet processing |
| `running` | Actively processing batches |
| `completed` | All migrations applied successfully |
| `failed` | A migration errored during execution |
| `canceled` | Run was canceled via CLI |
| `dry_run` | Plan computed without committing writes |
| `noop` | No pending migrations to apply |
| `drift_blocked` | Drift detected and blocked by policy |
Next Steps [#next-steps]
# RLS (/docs/orm/rls)
import { InfoIcon, AlertTriangle } from "lucide-react"
Define Policies [#define-policies]
Policies live alongside your table definition. Use `convexTable.withRLS` and pass an array of `rlsPolicy` rules that reference table columns and the request context.
A `secrets` table where each user can only access their own rows:
```ts title="convex/schema.ts" showLineNumbers {3-4,6-24}
import { convexTable, rlsPolicy, text, id, eq } from 'kitcn/orm';
export const secrets = convexTable.withRLS(
'secrets',
{
value: text().notNull(),
ownerId: id('users').notNull(),
},
(t) => [
rlsPolicy('read_own', {
for: 'select',
using: (ctx) => eq(t.ownerId, ctx.viewerId),
}),
rlsPolicy('insert_own', {
for: 'insert',
withCheck: (ctx) => eq(t.ownerId, ctx.viewerId),
}),
rlsPolicy('update_own', {
for: 'update',
using: (ctx) => eq(t.ownerId, ctx.viewerId),
withCheck: (ctx) => eq(t.ownerId, ctx.viewerId),
}),
rlsPolicy('delete_own', {
for: 'delete',
using: (ctx) => eq(t.ownerId, ctx.viewerId),
}),
]
);
```
}>
**Note:** If you add at least one `rlsPolicy(...)` to a table, RLS is enabled automatically. You don't need to call `withRLS(...)` explicitly.
Policy operations [#policy-operations]
Each policy targets a specific operation via the `for` field:
| Operation | Clause | When it runs |
| --------- | --------------------- | ---------------------------------------------------- |
| `select` | `using` | Filters rows after fetch |
| `insert` | `withCheck` | Validates new rows before write |
| `update` | `using` + `withCheck` | Filters existing rows, then validates the new values |
| `delete` | `using` | Filters rows before delete |
Enable RLS without policies [#enable-rls-without-policies]
You can also enable RLS without adding policies. This effectively locks the table -- no rows are accessible through `ctx.orm` until you add permissive policies.
```ts showLineNumbers
const secrets = convexTable.withRLS('secrets', {
value: text().notNull(),
});
```
Context Setup [#context-setup]
Before policies can run, you need to wire up the ORM with RLS enabled. Do this once in your context layer so every handler gets a policy-aware `ctx.orm`.
```ts title="convex/lib/crpc.ts" showLineNumbers {2,5}
import type { GenericDatabaseWriter } from 'convex/server';
import { orm } from '../functions/generated/server';
export const withOrm = }>(ctx: Ctx) => {
return orm.with(ctx, { rls: { ctx } });
};
```
The `rls: { ctx }` option tells the ORM to evaluate policies using the current request context (which includes `ctx.viewerId`, auth info, etc.).
Use in Handlers [#use-in-handlers]
With the context wired up, all ORM reads and writes through `ctx.orm` automatically enforce your policies. No extra code needed in each handler.
```ts title="convex/functions/secrets.ts" showLineNumbers
import { publicQuery } from '../lib/crpc';
export const listSecrets = publicQuery.query(async ({ ctx }) => {
return ctx.orm.query.secrets.findMany();
});
```
}>
**Note:** RLS policies are evaluated post-fetch -- after query filters run. They also apply to nested relation loading, so related rows are filtered too.
Bypass Rules [#bypass-rules]
Sometimes trusted server-side code needs unrestricted access -- migrations, admin dashboards, or background jobs. Use `ctx.orm.skipRules` to bypass all RLS policies:
```ts showLineNumbers
await ctx.orm.skipRules.query.secrets.findMany();
```
}>
**Important:** `ctx.db` also bypasses RLS entirely. Only ORM access through `ctx.orm` enforces policies. Keep this in mind if you mix raw Convex calls with ORM calls.
Roles [#roles]
For more granular control, you can scope policies to specific roles using `to` clauses. Roles are optional -- if you don't use `to`, the policy applies to all users.
First, define a role and reference it in your policy:
```ts showLineNumbers {3,7}
import { rlsRole, rlsPolicy } from 'kitcn/orm';
const admin = rlsRole('admin');
rlsPolicy('admin_only', {
for: 'select',
to: admin,
using: (ctx, t) => eq(t.ownerId, ctx.viewerId),
});
```
Then provide a `roleResolver` when building `ctx.orm` so it knows how to determine the current user's roles:
```ts showLineNumbers {3-4}
const ormDb = orm.db(ctx, {
rls: {
ctx,
roleResolver: (ctx) => ctx.roles ?? [],
},
});
```
}>
**Important:** A `roleResolver` is required by any policy scoped to a named role. Every query and mutation validates this for each table it touches before reading rows, so a missing resolver throws `RLS_ROLE_RESOLVER_REQUIRED` even when the table is empty, rather than granting the policy to every caller. Use `ctx.orm.skipRules` for trusted code that intentionally bypasses policies.
The SQL pseudo-roles `'public'`, `'current_user'`, `'current_role'`, and `'session_user'` resolve to the calling user, so they apply to everyone and need no `roleResolver`.
Null Handling [#null-handling]
Policy expressions follow SQL null semantics: a comparison against a missing document field or a missing context value evaluates to unknown, and unknown is treated as a denial.
```ts showLineNumbers
rlsPolicy('read_own', {
for: 'select',
using: (ctx, t) => eq(t.ownerId, ctx.viewerId),
});
```
An unauthenticated caller (`ctx.viewerId` is `undefined`) is denied rows whose `ownerId` is unset -- the two missing values never match each other. Use `isNull` when you deliberately want to match rows with an absent column.
Relations [#relations]
Loading a relation with `with` enforces the related table's policies. For many-to-many relations, the junction table's policies are enforced too, so a caller only traverses the links they are allowed to read.
```ts showLineNumbers
// memberships carries `rlsPolicy('read_own', { using: (ctx, t) => eq(t.userId, ctx.viewerId) })`
await ctx.orm.query.users.findMany({ with: { organizations: true } });
```
Other users come back with an empty `organizations` array, because their membership rows fail the junction policy.
Drizzle Differences [#drizzle-differences]
ORM RLS is inspired by Drizzle but runs differently since Convex is not a SQL database. Policies are enforced at runtime in your application layer, not inside a database engine. This means `ctx.db` (raw Convex access) always bypasses RLS -- only `ctx.orm` enforces your rules.
}>
**Important:** If RLS is enabled on a table but no permissive policies exist, all access through `ctx.orm` is denied by default. You must add at least one permissive policy to grant access.
Foreign-key cascade fan-out (`cascade`, `set null`, `set default`) runs after the root mutation row passes RLS. Fan-out writes are executed as system actions and do not re-check child-table RLS policies.
}>
**Important:** Be cautious with cascades on RLS-protected tables. The root write is checked, but cascaded child writes bypass RLS entirely.
Summary [#summary]
Row-level security lets you co-locate access rules with your schema, keeping authorization logic out of individual handlers. Policies are enforced automatically on all `ctx.orm` reads and writes, with `skipRules` available for trusted server code.
Next Steps [#next-steps]
# Error Handling (/docs/react/error-handling)
import { InfoIcon } from "lucide-react"
Server Errors [#server-errors]
When a procedure throws `CRPCError`, it arrives on the client as a `ConvexError`. Access the built-in fields and any custom payload via `error.data`:
```tsx showLineNumbers {2,5-6}
// Server throws:
throw new CRPCError({
code: 'CONFLICT',
message: 'Domain already exists',
data: { existingSiteId: 'site_123' },
});
// Client receives:
error.data?.message // 'Domain already exists'
error.data?.existingSiteId // 'site_123'
```
Query Errors [#query-errors]
Handle errors from queries:
```tsx showLineNumbers {1,3-4}
const { data, error, isError } = useQuery(crpc.posts.get.queryOptions({ id }));
if (isError) {
const message = error.data?.message ?? 'Something went wrong';
}
```
Mutation Errors [#mutation-errors]
Handle errors with callbacks:
```tsx showLineNumbers {2-5}
const mutation = useMutation(
crpc.posts.create.mutationOptions({
onError: (error) => {
toast.error(error.data?.message ?? 'Failed to create post');
},
})
);
```
Prefer `error.data?.message` before `error.message`. `data.message` is the
clean app-level message from `CRPCError`. `error.message` can include noisier
transport details.
Or use `toast.promise` for a cleaner pattern:
```tsx showLineNumbers {1-5}
toast.promise(mutation.mutateAsync(data), {
loading: 'Creating...',
success: 'Created!',
error: (e) => e.data?.message ?? 'Failed',
});
```
If most mutations should toast the same way, move that to your `QueryClient`
and keep components lean:
```tsx showLineNumbers {12-27}
import { QueryCache, QueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: (error) => {
if (isCRPCClientError(error)) {
console.log(`[CRPC] ${error.code}:`, error.functionName);
}
},
}),
defaultOptions: {
mutations: {
onError: (err, _variables, _context, mutation) => {
const error = err as Error & { data?: { message?: string } };
const meta = mutation.meta as
| { errorMessage?: string; skipErrorToast?: boolean }
| undefined;
if (meta?.skipErrorToast) return;
toast.error(
error.data?.message ??
meta?.errorMessage ??
error.message ??
'Something went wrong'
);
},
},
},
});
```
Then set a fallback message per mutation without repeating `onError`:
```tsx showLineNumbers {2}
const sendMessage = useMutation(
crpc.messages.sendMessage.mutationOptions({
meta: { errorMessage: 'Failed to send message' },
onSuccess: () => {
setDraft('');
toast.success('Message sent');
},
})
);
```
Type-safe Error Access [#type-safe-error-access]
When using try/catch:
```tsx showLineNumbers {4-5}
try {
await mutation.mutateAsync(data);
} catch (err) {
const error = err as Error & {
data?: {
message?: string;
existingSiteId?: string;
};
};
toast.error(error.data?.message ?? 'Operation failed');
}
```
Client Errors [#client-errors]
`CRPCClientError` is thrown client-side when queries are skipped due to auth requirements:
```tsx showLineNumbers {1,3-5}
import { CRPCClientError, isCRPCClientError } from 'kitcn/crpc';
if (isCRPCClientError(error)) {
console.log(error.code); // 'UNAUTHORIZED'
console.log(error.functionName); // 'user:getSettings'
}
```
See [API Reference](#error-codes) for all available error codes.
Check Specific Codes [#check-specific-codes]
Check for specific error codes:
```tsx showLineNumbers {1,3-4}
import { isCRPCErrorCode } from 'kitcn/crpc';
if (isCRPCErrorCode(error, 'UNAUTHORIZED')) {
router.push('/login');
}
```
Global Error Handling [#global-error-handling]
Handle errors globally in the QueryClient:
```tsx showLineNumbers {5-26}
import { QueryCache, QueryClient } from '@tanstack/react-query';
import { isCRPCClientError } from 'kitcn/crpc';
import { toast } from 'sonner';
const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: (error) => {
if (isCRPCClientError(error)) {
console.log(`[CRPC] ${error.code}:`, error.functionName);
}
},
}),
defaultOptions: {
mutations: {
onError: (err, _variables, _context, mutation) => {
const error = err as Error & { data?: { message?: string } };
const meta = mutation.meta as
| { errorMessage?: string; skipErrorToast?: boolean }
| undefined;
if (meta?.skipErrorToast) return;
toast.error(
error.data?.message ??
meta?.errorMessage ??
error.message ??
'Something went wrong'
);
},
},
},
});
```
}>
**Tip:** Use global mutation error handling for user-facing fallback toasts. Keep per-mutation `onError` only for custom flows that genuinely need bespoke behavior.
Next Steps [#next-steps]
API Reference [#api-reference]
Error Codes [#error-codes]
| Code | Description |
| ------------------- | ---------------------- |
| `UNAUTHORIZED` | Missing authentication |
| `FORBIDDEN` | Not authorized |
| `NOT_FOUND` | Resource not found |
| `BAD_REQUEST` | Invalid input |
| `TOO_MANY_REQUESTS` | Rate limited |
# Setup (/docs/react)
import { InfoIcon } from "lucide-react"
Installation [#installation]
First, install the required packages:
Create the cRPC Context [#create-the-crpc-context]
Create a file that exports your cRPC hooks:
```tsx title="src/lib/convex/crpc.tsx" showLineNumbers {6-10}
import { api } from '@convex/api';
import { createCRPCContext } from 'kitcn/react';
export const { CRPCProvider, useCRPC, useCRPCClient } = createCRPCContext({
api,
convexSiteUrl: process.env.NEXT_PUBLIC_CONVEX_SITE_URL!,
});
```
}>
**Note:** `api` is generated by `kitcn dev` and already includes procedure metadata used by cRPC.
Payload Transformer (Optional) [#payload-transformer-optional]
`createCRPCContext` also accepts `transformer`.
* `Date` support is always enabled (cannot be disabled)
* The reserved wire tag is `$date`
* Use custom transformer only when you need extra wire-safe types
* Custom transformer is additive to the built-in Date transformer
```tsx title="src/lib/convex/crpc.tsx" showLineNumbers
import { api } from '@convex/api';
import { createTaggedTransformer } from 'kitcn/crpc';
import { createCRPCContext } from 'kitcn/react';
const transformer = createTaggedTransformer([
// Add custom codecs here (Date is already built-in)
]);
export const { CRPCProvider, useCRPC, useCRPCClient } = createCRPCContext({
api,
convexSiteUrl: process.env.NEXT_PUBLIC_CONVEX_SITE_URL!,
transformer,
});
```
Exports [#exports]
See [createCRPCContext returns](#createcrpccontext) in the API Reference.
Create a QueryClient [#create-a-queryclient]
cRPC sets `staleTime: Infinity` and `refetch*: false` on each query automatically (Convex handles freshness via WebSocket):
```ts title="src/lib/convex/query-client.ts" showLineNumbers {11-16,23-30,40-55}
import {
type DefaultOptions,
defaultShouldDehydrateQuery,
QueryCache,
QueryClient,
} from '@tanstack/react-query';
import { isCRPCClientError, isCRPCError } from 'kitcn/crpc';
import { toast } from 'sonner';
import SuperJSON from 'superjson';
/** Shared hydration config for SSR (client + server) */
export const hydrationConfig: Pick = {
dehydrate: {
serializeData: SuperJSON.serialize,
shouldDehydrateQuery: (query) =>
defaultShouldDehydrateQuery(query) || query.state.status === 'pending',
shouldRedactErrors: () => false,
},
hydrate: {
deserializeData: SuperJSON.deserialize,
},
};
export function createQueryClient() {
return new QueryClient({
queryCache: new QueryCache({
onError: (error) => {
if (isCRPCClientError(error)) {
console.log(`[CRPC] ${error.code}:`, error.functionName);
}
},
}),
defaultOptions: {
...hydrationConfig,
mutations: {
onError: (err) => {
const error = err as Error & { data?: { message?: string } };
toast.error(error.data?.message || error.message);
},
},
queries: {
retry: (failureCount, error) => {
// Don't retry deterministic CRPC errors (auth, validation, HTTP 4xx)
if (isCRPCError(error)) return false;
const message =
error instanceof Error ? error.message : String(error);
// Retry timeouts
if (message.includes('timed out') && failureCount < 3) {
console.warn(
`[QueryClient] Retrying timed out query (attempt ${failureCount + 1}/3)`
);
return true;
}
return failureCount < 3;
},
retryDelay: (attemptIndex) =>
Math.min(2000 * 2 ** attemptIndex, 30_000),
},
},
});
}
```
Provider Setup [#provider-setup]
Now let's wire everything together. Choose the setup that matches your app.
Without Auth [#without-auth]
For public-only apps without authentication:
```tsx title="src/lib/convex/convex-provider.tsx" showLineNumbers {7,14-18,22-25}
'use client';
import { QueryClientProvider } from '@tanstack/react-query';
import {
ConvexProvider,
ConvexReactClient,
getQueryClientSingleton,
getConvexQueryClientSingleton,
} from 'kitcn/react';
import { CRPCProvider } from '@/lib/convex/crpc';
import { createQueryClient } from '@/lib/convex/query-client';
const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!);
export function AppConvexProvider({ children }: { children: React.ReactNode }) {
return (
{children}
);
}
function QueryProvider({ children }: { children: React.ReactNode }) {
const queryClient = getQueryClientSingleton(createQueryClient);
const convexQueryClient = getConvexQueryClientSingleton({
convex,
queryClient,
});
return (
{children}
);
}
```
With Auth [#with-auth]
For apps with authentication, use `ConvexAuthProvider` instead of `ConvexProvider`:
```tsx title="src/lib/convex/convex-provider.tsx" showLineNumbers {5-6,11,18-21,25-30,33-36}
'use client';
import { QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { ConvexAuthProvider } from 'kitcn/auth/client';
import {
ConvexReactClient,
getConvexQueryClientSingleton,
getQueryClientSingleton,
} from 'kitcn/react';
import { useRouter } from 'next/navigation';
import type { ReactNode } from 'react';
import { authClient } from '@/lib/convex/auth-client';
import { CRPCProvider } from '@/lib/convex/crpc';
import { createQueryClient } from '@/lib/convex/query-client';
const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!);
export function AppConvexProvider({
children,
token,
}: {
children: ReactNode;
token?: string;
}) {
const router = useRouter();
const queryClient = getQueryClientSingleton(createQueryClient);
const convexQueryClient = getConvexQueryClientSingleton({
convex,
queryClient,
});
return (
router.push('/login')}
onQueryUnauthorized={() => router.push('/login')}
>
{children}
);
}
```
Key differences from "Without Auth":
| Feature | Description |
| --------------------- | ------------------------------------------- |
| `ConvexAuthProvider` | Wraps everything with auth state management |
| `convexQueryClient` | Syncs auth state to the shared query client |
| `onQueryUnauthorized` | Handles auth failures with redirect |
See [Auth Server](/docs/auth/server) for backend configuration.
Next Steps [#next-steps]
API Reference [#api-reference]
createCRPCContext [#createcrpccontext]
`createCRPCContext` returns:
| Export | Description |
| --------------- | ------------------------------------------------------------------------ |
| `CRPCProvider` | React context provider for cRPC |
| `useCRPC` | Hook returning the cRPC proxy for `queryOptions`/`mutationOptions` |
| `useCRPCClient` | Hook returning the typed vanilla cRPC client for direct procedural calls |
Singleton Helpers [#singleton-helpers]
cRPC provides singleton helpers to ensure consistent client instances across renders and SSR.
getQueryClientSingleton [#getqueryclientsingleton]
Returns the same QueryClient instance on the client, creates a fresh one during SSR:
```ts showLineNumbers
const queryClient = getQueryClientSingleton(createQueryClient);
```
getConvexQueryClientSingleton [#getconvexqueryclientsingleton]
Creates and connects the ConvexQueryClient that bridges TanStack Query with Convex subscriptions:
```ts showLineNumbers {1-5}
const convexQueryClient = getConvexQueryClientSingleton({
convex, // ConvexReactClient instance
queryClient, // TanStack QueryClient
unsubscribeDelay, // Optional: delay before unsubscribing (default: 3s)
});
```
unsubscribeDelay [#unsubscribedelay]
Controls how long subscriptions stay open after unmount. Prevents wasteful unsubscribe/subscribe cycles from React StrictMode and quick back/forward navigation:
| Value | Use Case |
| ---------------- | ---------------------------------------------------------------- |
| `0` | Unsubscribe immediately (minimal server resources) |
| `3000` (default) | Covers StrictMode + quick "oops" back navigations |
| `10000` | Generous buffer for browsing patterns with frequent back/forward |
}>
**Tip:** Even after unsubscribing, cached data persists for `gcTime` (5 min). On back-navigation, you see cached data instantly while a new subscription fetches any updates.
ConvexQueryClient [#convexqueryclient]
The `ConvexQueryClient` bridges TanStack Query with Convex's real-time subscriptions:
| Feature | Description |
| ---------------------- | ------------------------------------------------------------ |
| Real-time sync | WebSocket subscriptions update TanStack Query cache |
| Auth-aware queries | Skips queries marked `auth: 'required'` when unauthenticated |
| Subscription lifecycle | Subscribes on mount, unsubscribes after delay on unmount |
Caching vs Subscriptions [#caching-vs-subscriptions]
Unlike traditional REST APIs that use polling, Convex pushes updates via WebSocket. This changes how caching works:
```
┌─────────────────────────────────────────────────────────────────┐
│ Traditional REST API │
│ │
│ fetch() ──► cache ──► staleTime expires ──► refetch() │
│ │ │
│ (pull model) │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Convex + cRPC │
│ │
│ useQuery() ──► WebSocket subscription ──► real-time updates │
│ │ │ │
│ (push model) cache always fresh │
└─────────────────────────────────────────────────────────────────┘
```
**Key QueryClient defaults:**
| Setting | Value | Why |
| ---------------------- | ---------- | ----------------------------------------------------------- |
| `staleTime` | `Infinity` | Data is never "stale" - Convex pushes updates via WebSocket |
| `gcTime` | `5 min` | Cached data persists for back-navigation after unmount |
| `refetchOnMount` | `false` | No need to refetch - subscription provides latest data |
| `refetchOnWindowFocus` | `false` | WebSocket already pushed any changes |
These apply to the WebSocket push model. `crpc.http.*` routes are pull-model and carry their own `staleTime` of 30 seconds with `refetchOnMount` left at TanStack's default — see [HTTP Routes](/docs/server/http).
# Type Inference (/docs/react/infer-types)
import { InfoIcon } from "lucide-react"
Usage [#usage]
Access nested procedure types using bracket notation:
```tsx title="src/components/user-card.tsx" showLineNumbers {4-6,9-10}
import type { ApiInputs, ApiOutputs } from '@convex/api';
// Output types
type User = ApiOutputs['user']['get'];
type UserList = ApiOutputs['user']['list'];
type Post = ApiOutputs['post']['get'];
// Input types
type GetUserArgs = ApiInputs['user']['get'];
type CreatePostArgs = ApiInputs['post']['create'];
```
In Components [#in-components]
Use output types for component props:
```tsx title="src/components/user-profile.tsx" showLineNumbers {3,5}
import type { ApiOutputs } from '@convex/api';
type User = ApiOutputs['user']['get'];
function UserProfile({ user }: { user: User }) {
return (
{user.name}
{user.email}
);
}
```
In Utility Functions [#in-utility-functions]
Use both input and output types for type-safe utilities:
```tsx title="src/lib/utils/user.ts" showLineNumbers {3-4,6-8,10-12}
import type { ApiInputs, ApiOutputs } from '@convex/api';
type User = ApiOutputs['user']['get'];
type UpdateUserArgs = ApiInputs['user']['update'];
function formatUserName(user: User): string {
return user.name ?? 'Anonymous';
}
function validateUpdateArgs(args: UpdateUserArgs): boolean {
return !!args.name || !!args.email;
}
```
Deep Nested Types [#deep-nested-types]
For deeply nested APIs, chain bracket notation:
```ts showLineNumbers {2,5}
// api.organization.members.list
type OrgMember = ApiOutputs['organization']['members']['list'][number];
// api.project.settings.get
type ProjectSettings = ApiOutputs['project']['settings']['get'];
```
}>
**Tip:** Use `[number]` to extract the item type from array return types.
Next Steps [#next-steps]
# Infinite Queries (/docs/react/infinite-queries)
import { InfoIcon } from "lucide-react"
```tsx showLineNumbers {1,7-9,14}
import { useInfiniteQuery } from 'kitcn/react';
import { useCRPC } from '@/lib/crpc';
function SessionList({ userId }: { userId: string }) {
const crpc = useCRPC();
// limit comes from server's .paginated(20) - no need to specify
const { data, fetchNextPage, hasNextPage, isLoading } = useInfiniteQuery(
crpc.session.list.infiniteQueryOptions({ userId })
);
if (isLoading) return
Loading...
;
return (
{data.map((session) => (
))}
{hasNextPage && (
)}
);
}
```
Signature [#signature]
The `infiniteQueryOptions` method creates options for infinite queries:
```ts showLineNumbers {2,5,8,11}
// Get infinite query options from cRPC proxy
// limit defaults to server's .paginated(limit) value
const options = crpc.session.list.infiniteQueryOptions({ userId });
// Or override with a lower value
const options = crpc.session.list.infiniteQueryOptions({ userId }, { limit: 10 });
// Use in hook - options include the function reference
useInfiniteQuery(options)
// Access page size
const limit = crpc.session.list.meta.limit;
```
Prefetching [#prefetching]
Prefetch the first page for instant navigation:
```tsx showLineNumbers {4-6}
// In route loader or parent component
const crpc = useCRPC();
const queryClient = useQueryClient();
await queryClient.prefetchQuery(
crpc.session.list.infiniteQueryOptions({ userId })
);
```
See the [Return Value](#return-value) reference for a full list of properties returned by `useInfiniteQuery`.
infiniteQueryOptions [#infinitequeryoptions]
Options passed to `crpc.*.infiniteQueryOptions()`:
| Option | Type | Description |
| ------------ | --------- | ------------------------------------------------------------------------------------------------------------------------- |
| `limit` | `number` | Items per page. Optional if defined in `.paginated(limit)` on server. Can only be less than or equal to the server limit. |
| `skipUnauth` | `boolean` | Skip when unauthenticated |
Other TanStack Query options are also supported.
Placeholder Data [#placeholder-data]
Use `placeholderData` for skeleton UIs while loading:
```tsx showLineNumbers {4-12,17}
import type { Id } from '@convex/dataModel';
const crpc = useCRPC();
// Use .meta.limit to match server's page size for placeholder data
const { data, isPlaceholderData } = useInfiniteQuery(
crpc.session.list.infiniteQueryOptions(
{},
{
placeholderData: Array.from({ length: crpc.session.list.meta.limit }).map((_, i) => ({
id: i.toString() as Id<'session'>,
token: 'Loading...',
expiresAt: 0,
})),
}
)
);
return (
);
}
```
Backend Setup [#backend-setup]
Paginated queries use `.paginated({ limit, item })` to add pagination input automatically, set the max items per page, and auto-wrap the output:
```ts title="convex/functions/session.ts" showLineNumbers {8,10-16}
import * as z from 'zod';
import { publicQuery } from './lib/crpc';
const SessionSchema = z.object({
id: z.string(),
userId: z.string(),
token: z.string(),
});
export const list = publicQuery
.input(z.object({ userId: z.string().optional() }))
.paginated({ limit: 20, item: SessionSchema })
.query(async ({ ctx, input }) => {
// cursor, endCursor, and limit are automatically added to input
return ctx.orm.query.session.findMany({
where: input.userId ? { userId: input.userId } : undefined,
orderBy: { createdAt: 'desc' },
cursor: input.cursor,
endCursor: input.endCursor,
limit: input.limit,
});
});
```
The `.paginated({ limit, item })` method:
* Adds flat pagination fields to your input: `cursor`, `endCursor`, and `limit`
* Accepts the full Convex page result, including `pageStatus` and `splitCursor`
| Input Field | Type | Description |
| ----------- | ----------------------------- | ------------------------------------------------------------ |
| `cursor` | `string \| null` | Pagination cursor |
| `endCursor` | `string \| null \| undefined` | Reactive page boundary controlled by the infinite-query hook |
| `limit` | `number` | Items to fetch (capped at server limit) |
}>
**Important:** Always call `.paginated({ limit, item })` before `.query()`. Pass `input.cursor`, `input.endCursor`, and `input.limit` to the matching ORM `findMany()` fields so reactive page splits stay bounded.
Conditional Queries [#conditional-queries]
Use `enabled` to conditionally run queries:
```tsx showLineNumbers {4-7}
const crpc = useCRPC();
const [userId, setUserId] = useState(null);
const { data } = useInfiniteQuery(
crpc.session.list.infiniteQueryOptions(
{ userId: userId! },
{ limit: 20, enabled: !!userId }
)
);
```
Real-time Updates [#real-time-updates]
Each page maintains its own WebSocket subscription. When data changes on the server:
* Items are automatically updated in place
* New items appear in the appropriate page
* Deleted items are removed
* Oversized reactive pages split into adjacent, boundary-pinned subscriptions
* An invalid loaded-page cursor resets the list cleanly to its first page
```tsx showLineNumbers {4-6}
const crpc = useCRPC();
// This list updates in real-time when sessions are created/updated/deleted
const { data } = useInfiniteQuery(
crpc.session.list.infiniteQueryOptions({ userId })
);
```
Next Steps [#next-steps]
API Reference [#api-reference]
Return Value [#return-value]
Here's what `useInfiniteQuery` returns:
| Property | Type | Description |
| ---------------------- | ------------------ | --------------------------------------------------------------------- |
| `data` | `T[]` | Flattened array of all loaded items |
| `pages` | `T[][]` | Array of page arrays (raw, not flattened) |
| `fetchNextPage` | `(limit?) => void` | Load next page |
| `hasNextPage` | `boolean` | Whether more pages exist |
| `isLoading` | `boolean` | Loading first page |
| `isFetchingNextPage` | `boolean` | Loading additional pages |
| `isFetchNextPageError` | `boolean` | Whether fetching next page failed |
| `isPlaceholderData` | `boolean` | Showing placeholder data |
| `status` | `PaginationStatus` | `'LoadingFirstPage' \| 'LoadingMore' \| 'CanLoadMore' \| 'Exhausted'` |
| `error` | `Error \| null` | First error encountered |
| `isFetching` | `boolean` | Any fetch in progress |
| `isSuccess` | `boolean` | Query succeeded |
Extends TanStack Query's `UseQueryResult`.
# Mutations (/docs/react/mutations)
import { InfoIcon } from "lucide-react"
```tsx showLineNumbers {3,5-6,10-11}
import { useMutation } from '@tanstack/react-query';
import { useCRPC } from '@/lib/convex/crpc';
function CreateUser() {
const crpc = useCRPC();
const createUser = useMutation(crpc.user.create.mutationOptions());
return (
);
}
```
mutationOptions [#mutationoptions]
The `mutationOptions` method creates options for TanStack Query's `useMutation` hook.
```tsx showLineNumbers {4,7-15}
const crpc = useCRPC();
// Basic usage
const createUser = useMutation(crpc.user.create.mutationOptions());
// With callbacks
const updateUser = useMutation(
crpc.user.update.mutationOptions({
onSuccess: (data) => {
toast.success('Updated successfully');
},
onError: (error) => {
toast.error(error.data?.message ?? 'Update failed');
},
})
);
```
See [API Reference](#mutationoptions-1) for the full signature and options.
Convex optimistic updates [#convex-optimistic-updates]
Pass `optimisticUpdate` to update Convex's local query store while a mutation
is pending. Convex replays the update when query data changes and rolls it back
when the mutation completes.
```tsx
const renameTodo = useMutation(
crpc.todos.rename.mutationOptions({
optimisticUpdate: (localStore, args) => {
const todos = localStore.getQuery(api.todos.list, {});
if (!todos) return;
localStore.setQuery(
api.todos.list,
{},
todos.map((todo) =>
todo._id === args.id ? { ...todo, title: args.title } : todo
)
);
},
})
);
```
`optimisticUpdate` is a Convex option, not a TanStack Query callback. Its
arguments and query values use Convex's wire shape.
Mutation Keys [#mutation-keys]
Get type-safe mutation keys for cache operations:
```tsx showLineNumbers {4-5}
const crpc = useCRPC();
// Get mutation key
const mutationKey = crpc.user.create.mutationKey();
// => ['convexMutation', 'user:create']
```
Common Patterns [#common-patterns]
Toast Promise [#toast-promise]
Use `toast.promise` for loading/success/error states:
```tsx showLineNumbers {1,4-8}
const createProject = useMutation(crpc.project.create.mutationOptions());
const onSubmit = (data: FormData) => {
toast.promise(createProject.mutateAsync({ title: data.title }), {
loading: 'Creating project...',
success: 'Project created!',
error: (e) => e.data?.message ?? 'Failed to create project',
});
};
```
Form Submission [#form-submission]
Handle form submission with cleanup:
```tsx showLineNumbers {1-10,13}
const updateUser = useMutation(
crpc.user.update.mutationOptions({
onSuccess: () => {
form.reset();
closeModal();
toast.success('Profile updated');
},
onError: () => {
toast.error('Update failed');
},
})
);
const onSubmit = (data: FormData) => {
updateUser.mutate(data);
};
```
Inline Callbacks [#inline-callbacks]
Pass callbacks directly to `mutate`:
```tsx showLineNumbers {1,3-8}
const deleteSession = useMutation(crpc.session.delete.mutationOptions());
deleteSession.mutate(
{ id: sessionId },
{
onSuccess: () => router.push('/sessions'),
onError: () => toast.error('Delete failed'),
}
);
```
Actions as Mutations [#actions-as-mutations]
Convex actions can be used as mutations for external API calls:
```tsx showLineNumbers {2,4-7,10-11}
// Actions work with mutationOptions
const scrapeLink = useMutation(crpc.scraper.scrapeLink.mutationOptions());
useEffect(() => {
if (url) {
scrapeLink.mutate({ url });
}
}, [url]);
// Access mutation state
if (scrapeLink.isPending) return ;
if (scrapeLink.data) return ;
```
}>
**Note:** Actions don't have real-time subscriptions. Use `mutationOptions` for one-shot calls to external APIs, or `queryOptions` if you need query caching.
Next Steps [#next-steps]
API Reference [#api-reference]
mutationOptions [#mutationoptions-1]
```ts showLineNumbers
crpc.path.to.mutation.mutationOptions(
options? // TanStack Query mutation options (except mutationFn)
)
```
All standard [TanStack Query mutation options](https://tanstack.com/query/latest/docs/framework/react/reference/useMutation) are supported except `mutationFn` (reserved):
| Option | Type | Description |
| ----------- | ------------------------------------------- | ----------------------------------------------- |
| `onSuccess` | `(data, variables, context) => void` | Called on successful mutation |
| `onError` | `(error, variables, context) => void` | Called on error |
| `onMutate` | `(variables) => context` | Called before mutation (for optimistic updates) |
| `onSettled` | `(data, error, variables, context) => void` | Called on completion |
| `retry` | `number \| boolean` | Retry failed mutations |
# Queries (/docs/react/queries)
import { InfoIcon } from "lucide-react"
```tsx showLineNumbers {3,5-7}
import { useQuery } from '@tanstack/react-query';
import { useCRPC } from '@/lib/convex/crpc';
function UserProfile({ id }: { id: string }) {
const crpc = useCRPC();
const { data, isPending } = useQuery(
crpc.user.get.queryOptions({ id })
);
if (isPending) return
Loading...
;
return
{data?.name}
;
}
```
queryOptions [#queryoptions]
The `queryOptions` method creates options for TanStack Query's `useQuery` hook.
```tsx showLineNumbers {3,6,9-14}
const crpc = useCRPC();
// Basic usage
const { data } = useQuery(crpc.user.list.queryOptions({}));
// With arguments
const { data } = useQuery(crpc.user.get.queryOptions({ id: userId }));
// With TanStack Query options
const { data } = useQuery(
crpc.session.list.queryOptions(
{ userId },
{
enabled: !!userId,
placeholderData: [],
}
)
);
```
See [API Reference](#queryoptions-1) for the full signature and options.
With select [#with-select]
To transform query data with type inference, spread the query options and add `select`:
```tsx showLineNumbers {2-4}
const { data } = useSuspenseQuery({
...crpc.http.health.queryOptions(),
select: (data) => data.status,
});
// data: string (not { status: string })
```
TanStack Query infers the return type from the `select` function. Note that `select` cannot be passed directly to `queryOptions()`.
Real-time Updates [#real-time-updates]
By default, cRPC queries subscribe to Convex's WebSocket connection and automatically update when data changes on the server. This is the key difference from traditional REST APIs:
```tsx showLineNumbers {2,5}
// This query receives real-time updates automatically
const { data } = useQuery(crpc.messages.list.queryOptions({ chatId }));
// When any client creates a message, all subscribers see it instantly
const { mutate } = useMutation(crpc.messages.create.mutationOptions());
```
Disabling Subscriptions [#disabling-subscriptions]
For data that doesn't need real-time updates, disable subscriptions to reduce WebSocket traffic:
```tsx showLineNumbers {4,8}
// One-time fetch, no subscription
const { data } = useQuery(
crpc.analytics.getReport.queryOptions(
{ period: 'monthly' },
{ subscribe: false }
)
);
// Manually refresh with invalidateQueries
const queryClient = useQueryClient();
queryClient.invalidateQueries(crpc.analytics.getReport.queryFilter());
```
}>
**Note:** With `subscribe: false`, the query behaves like a standard TanStack Query fetch. Use `invalidateQueries` to manually refresh the data when needed.
Conditional Queries [#conditional-queries]
With enabled [#with-enabled]
Use `enabled` to conditionally run queries:
```tsx showLineNumbers {1,4-7}
const { data: user } = useQuery(crpc.user.get.queryOptions({ id: userId }));
// Only fetch settings after user is loaded
const { data: settings } = useQuery(
crpc.user.getSettings.queryOptions(
{ userId: user?.id },
{ enabled: !!user }
)
);
```
With skipToken [#with-skiptoken]
For type-safe conditional queries, pass `skipToken` directly to `queryOptions`:
```tsx showLineNumbers {1,3-4}
import { skipToken } from '@tanstack/react-query';
const { data } = useQuery(
crpc.user.get.queryOptions(userId ? { id: userId } : skipToken)
);
```
Auth-aware Queries [#auth-aware-queries]
cRPC provides two ways to handle authentication in queries.
skipUnauth [#skipunauth]
For queries that require authentication but shouldn't error when logged out:
```tsx showLineNumbers {3-5}
// Returns undefined instead of throwing when not authenticated
const { data } = useQuery(
crpc.user.getCurrentUser.queryOptions(
{},
{ skipUnauth: true }
)
);
```
This is useful for:
* Optional personalization on public pages
* Prefetching user data that may or may not exist
* Avoiding auth errors during SSR/hydration
Auth metadata [#auth-metadata]
Procedures with `.meta({ auth: 'required' })` have two key behaviors:
1. **During auth loading**: Query is disabled (waits for auth handshake to complete)
2. **After auth settles**: Query is skipped if user isn't authenticated
This prevents "unauthenticated" errors on first page load - the query won't run until we know the user's auth state.
```tsx showLineNumbers {4,12}
// Server-side (convex/functions/user.ts)
export const getSettings = c
.query()
.meta({ auth: 'required' }) // Won't run if not authenticated
.handler(async ({ ctx }) => {
return ctx.orm.query.user.findFirst({ where: { id: ctx.user.id } });
});
// Client-side - automatically skips when logged out
const { data } = useQuery(crpc.user.getSettings.queryOptions({}));
```
}>
**How Auth Loading Works:**
When your app loads, auth state is initially unknown (`isLoading: true`). How queries behave depends on their metadata:
| Procedure | Auth Loading | Logged Out |
| -------------------------------------- | ---------------- | ---------- |
| `publicQuery` | Runs immediately | Runs |
| `optionalAuthQuery` (auth: 'optional') | **Waits** | Runs |
| `authQuery` (auth: 'required') | **Waits** | **Skips** |
**Note:** These procedure builders (`authQuery`, etc.) already include the correct `.meta()` settings. Just use `authQuery` - no need to add `.meta({ auth: 'required' })` yourself.
**When to use each:**
| Use Case | Solution |
| ----------------------------------------------- | --------------------------------------------------- |
| Query needs `ctx.user` | Use `authQuery` (has `.meta({ auth: 'required' })`) |
| Public page with optional user data | Use `optionalAuthQuery` + check `ctx.user` |
| Fully public data | Use `publicQuery` |
| Auth query that shouldn't error when logged out | Add `skipUnauth: true` client-side |
Loading States [#loading-states]
Use `placeholderData` for skeleton UIs:
```tsx showLineNumbers {2-9,13}
const { data, isPlaceholderData } = useQuery(
crpc.user.list.queryOptions(
{},
{
placeholderData: {
users: [
{ id: '0' as Id<'user'>, name: 'Loading...' },
{ id: '2' as Id<'user'>, name: 'Loading...' },
],
},
}
)
);
return (
{data?.users.map((user) => (
))}
);
```
}>
**Warning:** Use static, predictable mock data in `placeholderData`. Random values cause hydration errors in SSR.
Query Keys [#query-keys]
Get type-safe query keys for cache manipulation:
```tsx showLineNumbers {4-5,8-9}
const crpc = useCRPC();
const queryClient = useQueryClient();
// Get query key
const queryKey = crpc.user.list.queryKey({});
// => ['convexQuery', 'user:list', {}]
// Read/write cache
const data = queryClient.getQueryData(queryKey);
queryClient.setQueryData(queryKey, newData);
```
}>
**Tip:** With real-time subscriptions (default), `invalidateQueries` is rarely needed since data updates automatically via WebSocket. Use it with `subscribe: false` queries for manual cache control.
Query Filters [#query-filters]
`queryFilter` builds a filter for `invalidateQueries`, `cancelQueries`, `removeQueries`, and friends. Omit `args` to match every args variant of the function:
```tsx showLineNumbers {2,5,8}
// Every cached variant of user.list
queryClient.invalidateQueries(crpc.user.list.queryFilter());
// Only variants whose args contain `{ status: 'active' }`
queryClient.invalidateQueries(crpc.user.list.queryFilter({ status: 'active' }));
// Combine with any other TanStack filter option
const filter = crpc.user.list.queryFilter(
{ status: 'active' },
{ predicate: (query) => query.state.dataUpdatedAt > Date.now() - 60000 }
);
queryClient.invalidateQueries(filter);
```
}>
**`queryKey` vs `queryFilter`:** `queryKey(args)` is the exact key an entry is stored under — use it for `getQueryData`/`setQueryData`. `queryFilter(args?)` matches on prefix, so partial args match every query whose args contain them.
Actions as Queries [#actions-as-queries]
Convex actions (which can call external APIs) can be used as one-shot queries. They don't support real-time subscriptions:
```tsx showLineNumbers {2}
// Actions are automatically detected and don't subscribe
const { data } = useQuery(crpc.ai.analyze.queryOptions({ documentId }));
```
Imperative Calls [#imperative-calls]
For queries in event handlers, effects, or callbacks, use `useCRPCClient`:
```tsx showLineNumbers {4-5}
import { useCRPCClient } from '@/lib/convex/crpc';
const client = useCRPCClient();
const user = await client.user.get.query({ id });
await client.user.update.mutate({ id, name: 'test' });
```
For cache-aware fetches in render context, use `queryClient.fetchQuery`:
```tsx showLineNumbers {5-6}
import { useQueryClient } from '@tanstack/react-query';
import { useCRPC } from '@/lib/convex/crpc';
const crpc = useCRPC();
const queryClient = useQueryClient();
const user = await queryClient.fetchQuery(crpc.user.get.queryOptions({ id }));
```
staticQueryOptions [#staticqueryoptions]
For prefetching in event handlers, use `staticQueryOptions`. Unlike `queryOptions`, it doesn't use hooks internally, so you can call it anywhere:
```tsx showLineNumbers {6-8}
import { useQueryClient } from '@tanstack/react-query';
import { useCRPC } from '@/lib/convex/crpc';
const crpc = useCRPC();
const queryClient = useQueryClient();
// Prefetch on hover - works in event handlers
const handleMouseEnter = () => {
queryClient.prefetchQuery(crpc.user.get.staticQueryOptions({ id }));
};
// Fetch on click
const handleClick = async () => {
const user = await queryClient.fetchQuery(
crpc.user.get.staticQueryOptions({ id })
);
};
```
}>
**Note:** `staticQueryOptions` doesn't include reactive auth state. Auth is handled at execution time by the `queryFn` - if the user isn't authenticated for a protected query, it will throw an `UNAUTHORIZED` error.
See [API Reference](#method-comparison) for a method comparison table.
Troubleshooting [#troubleshooting]
"Unauthenticated" error on first page load [#unauthenticated-error-on-first-page-load]
**Symptom:** Query throws UNAUTHORIZED error when page first loads, but works after refresh or navigation.
**Cause:** Query runs before auth handshake completes. Server receives request without token.
**Fix:** Ensure your server procedure uses `.meta({ auth: 'required' })`.
Next Steps [#next-steps]
API Reference [#api-reference]
queryOptions [#queryoptions-1]
```ts showLineNumbers
crpc.path.to.query.queryOptions(
args, // Function arguments (or {} for no args)
options? // TanStack Query options + cRPC options
)
```
cRPC extends TanStack Query options with:
| Option | Type | Description |
| ------------ | --------- | ------------------------------------------ |
| `skipUnauth` | `boolean` | Skip query when not authenticated |
| `subscribe` | `boolean` | Enable real-time updates (default: `true`) |
Plus all standard [TanStack Query options](https://tanstack.com/query/latest/docs/framework/react/reference/useQuery): `enabled`, `placeholderData`, `select`, `gcTime`, etc.
Method Comparison [#method-comparison]
| Method | Context | Caching | Use Case |
| ----------------------------- | ----------- | ---------- | --------------------------- |
| `client.*.query()` | Anywhere | None | Direct calls without cache |
| `crpc.*.queryOptions()` | Render only | Uses cache | Components (uses hooks) |
| `crpc.*.staticQueryOptions()` | Anywhere | Uses cache | Prefetching, event handlers |
# Context (/docs/server/context)
import { InfoIcon } from "lucide-react"
Base Context [#base-context]
Every procedure receives the base Convex context. Here's what you get out of the box:
```ts showLineNumbers {2-5}
export const list = publicQuery
.query(async ({ ctx }) => {
// ctx.orm - Database access (the ORM)
// ctx.auth - Authentication info
// ctx.storage - File storage
return ctx.orm.query.user.findMany({ limit: 100 });
});
```
Database (ctx.orm) [#database-ctxorm]
The database is your primary way to read and write data. In queries, you can only read. In mutations, you can read and write.
```ts showLineNumbers {1,4-5,8}
import { user } from './schema';
// Read
const one = await ctx.orm.query.user.findFirst({ where: { id: id } });
const many = await ctx.orm.query.user.findMany({ limit: 100 });
// Write (mutations only)
await ctx.orm.insert(user).values({ name: 'John', email: 'john@example.com' });
```
Authentication (ctx.auth) [#authentication-ctxauth]
Access the authenticated user's identity. This is the raw identity from your auth provider - use middleware to transform it into your app's user object.
```ts showLineNumbers {1-4}
const identity = await ctx.auth.getUserIdentity();
if (!identity) {
throw new CRPCError({ code: 'UNAUTHORIZED' });
}
// identity.subject - User ID from auth provider
// identity.email - Email (if available)
// identity.name - Name (if available)
```
}>
**Tip:** Rather than calling `getUserIdentity()` in every procedure, create an `authQuery` middleware that adds `ctx.user` automatically. See the next section.
Extending Context with Middleware [#extending-context-with-middleware]
Use middleware to add custom data to context - the authenticated user, feature flags, rate limit info, anything your procedures need.
The pattern is simple: fetch what you need, then call `next()` with the extended context.
```ts title="convex/lib/crpc.ts" showLineNumbers {3-18}
import { getSession } from 'kitcn/auth';
export const authQuery = c.query.use(async ({ ctx, next }) => {
const session = await getSession(ctx);
if (!session) {
throw new CRPCError({ code: 'UNAUTHORIZED', message: 'Not authenticated' });
}
const user = await ctx.orm.query.user.findFirst({
where: { id: session.userId },
});
if (!user) throw new CRPCError({ code: 'UNAUTHORIZED' });
return next({
ctx: { ...ctx, user: { id: user.id, session, ...user }, userId: user.id },
});
});
// Create mutation version with same pattern
export const authMutation = c.mutation.use(async ({ ctx, next }) => {
// ... same auth logic
});
```
Now when you use `authQuery` or `authMutation`, `ctx.user` and `ctx.userId` are guaranteed to exist:
```ts title="convex/functions/session.ts" showLineNumbers {1-4,6-16}
import * as z from 'zod';
import { authMutation } from '../lib/crpc';
import { session } from './schema';
export const create = authMutation
.input(z.object({ token: z.string() }))
.output(z.string())
.mutation(async ({ ctx, input }) => {
// ctx.user and ctx.userId are now available and typed!
const [row] = await ctx.orm
.insert(session)
.values({
...input,
userId: ctx.userId,
expiresAt: Date.now() + 30 * 24 * 60 * 60 * 1000,
})
.returning({ id: session.id });
return row.id;
});
```
Context in Actions [#context-in-actions]
Actions have a different context. Instead of direct database access, they call other procedures. Use the module runtime caller (for example `createUserCaller(ctx)`) for type-safe dispatch with full autocomplete:
```ts showLineNumbers {1,7-8,14}
import { createUserCaller } from './generated/user.runtime';
export const processAndSave = publicAction
.input(z.object({ data: z.string() }))
.action(async ({ ctx, input }) => {
// External API call (only possible in actions)
const result = await fetch('https://api.example.com/process', {
method: 'POST',
body: JSON.stringify({ data: input.data }),
});
// Call a mutation to save results
const caller = createUserCaller(ctx);
await caller.updateProfile({ data: await result.json() });
await caller.actions.syncExternalAnalytics({ userId: 'u_1' });
await caller.schedule.now.sendFollowUpEmail({ userId: 'u_1' });
});
```
}>
**Note:** Actions can't access the database directly. Module runtime callers (like `createUserCaller(ctx)`) dispatch root calls via `ctx.runQuery` / `ctx.runMutation`, action calls via `caller.actions.*` (which uses `ctx.runAction`), and scheduling via `caller.schedule.*` (which uses `ctx.scheduler`).
Next Steps [#next-steps]
# Error Handling (/docs/server/error-handling)
import { InfoIcon } from "lucide-react"
Throwing Errors [#throwing-errors]
The basic pattern is simple - import `CRPCError` and throw it with a code:
```ts showLineNumbers {1,3-6}
import { CRPCError } from 'kitcn/server';
throw new CRPCError({
code: 'NOT_FOUND',
message: 'Post not found',
});
```
Constructor [#constructor]
The `CRPCError` constructor accepts these parameters:
```ts showLineNumbers {2-4}
new CRPCError({
code: CRPCErrorCode; // Required - see table below
message?: string; // Optional, defaults to code
cause?: unknown; // Optional, original error
data?: Record; // Optional, must be Convex-serializable
});
```
With Custom Data [#with-custom-data]
Attach small Convex-serializable metadata when the client needs more than a
message:
```ts showLineNumbers {2-8}
throw new CRPCError({
code: 'CONFLICT',
message: 'This domain already exists.',
data: {
normalizedDomain,
existingSiteId,
},
});
```
With Cause [#with-cause]
When catching external errors, preserve the original stack trace using `cause`:
```ts showLineNumbers {5-8}
try {
await externalApi.call();
} catch (error) {
throw new CRPCError({
code: 'INTERNAL_SERVER_ERROR',
message: 'External API failed',
cause: error,
});
}
```
This helps with debugging by keeping the full error chain.
Error Codes [#error-codes]
For the full error code list, see [Error Codes](#error-codes-1) in API Reference below.
}>
**Tip:** Use `UNAUTHORIZED` when the user isn't logged in, and `FORBIDDEN` when they're logged in but don't have permission.
Helper Functions [#helper-functions]
cRPC provides several helper functions for working with errors.
getCRPCErrorFromUnknown [#getcrpcerrorfromunknown]
Wrap unknown errors in `CRPCError`. Useful in catch blocks:
```ts showLineNumbers {1,6}
import { getCRPCErrorFromUnknown } from 'kitcn/crpc';
try {
await riskyOperation();
} catch (error) {
throw getCRPCErrorFromUnknown(error);
}
```
getHTTPStatusCodeFromError [#gethttpstatuscodefromerror]
Get the HTTP status code from an error. Useful for HTTP endpoints:
```ts showLineNumbers {1,3}
import { getHTTPStatusCodeFromError } from 'kitcn/crpc';
const httpStatus = getHTTPStatusCodeFromError(error); // 404
```
isCRPCError [#iscrpcerror]
Type guard for checking if an error is a `CRPCError`:
```ts showLineNumbers {1,3-5}
import { isCRPCError } from 'kitcn/crpc';
if (isCRPCError(error)) {
console.log(error.code); // 'NOT_FOUND'
}
```
Common Patterns [#common-patterns]
Here are common error patterns.
Authorization [#authorization]
Check authentication in middleware and throw `UNAUTHORIZED`:
```ts title="convex/lib/crpc.ts" showLineNumbers {3-5}
const authMiddleware = c.middleware(async ({ ctx, next }) => {
const user = await getSessionUser(ctx);
if (!user) {
throw new CRPCError({ code: 'UNAUTHORIZED' });
}
return next({ ctx: { ...ctx, user } });
});
```
Not Found [#not-found]
Throw `NOT_FOUND` when a resource doesn't exist:
```ts title="convex/functions/posts.ts" showLineNumbers {5-15}
export const getById = authQuery
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const post = await ctx.orm.query.posts.findFirst({
where: { id: input.id },
});
if (!post) {
throw new CRPCError({
code: 'NOT_FOUND',
message: 'Post not found',
});
}
return post;
});
```
Rate Limiting [#rate-limiting]
Throw `TOO_MANY_REQUESTS` when rate limits are exceeded:
```ts showLineNumbers {2-5}
if (isRatelimited) {
throw new CRPCError({
code: 'TOO_MANY_REQUESTS',
message: 'Rate limit exceeded. Try again later.',
});
}
```
Permission Check [#permission-check]
Throw `FORBIDDEN` when the user doesn't have permission:
```ts showLineNumbers {2-5}
if (post.authorId !== ctx.userId) {
throw new CRPCError({
code: 'FORBIDDEN',
message: 'You can only edit your own posts',
});
}
```
Input Validation [#input-validation]
Throw `BAD_REQUEST` for custom validation failures (beyond Zod validation):
```ts showLineNumbers {2-5}
if (input.startDate > input.endDate) {
throw new CRPCError({
code: 'BAD_REQUEST',
message: 'Start date must be before end date',
});
}
```
Output Validation [#output-validation]
A value that fails the [`.output()` schema](/docs/server/procedures#output-validation) throws `INTERNAL_SERVER_ERROR` with message `Output validation failed`. Sanitized structural Zod issues arrive in `error.data.ZodError`, naming the issue code, path, and expected type when available. Custom messages and fields stay server-side because they can contain the rejected handler output:
```ts showLineNumbers
[{ expected: 'string', code: 'invalid_type', path: ['name'] }]
```
Next Steps [#next-steps]
API Reference [#api-reference]
Error Codes [#error-codes-1]
All available error codes with their HTTP status mappings:
| Code | Description | HTTP |
| ----------------------- | ------------------------------------------ | ---- |
| `BAD_REQUEST` | Invalid request or input validation failed | 400 |
| `UNAUTHORIZED` | Missing or invalid authentication | 401 |
| `PAYMENT_REQUIRED` | Payment required to access resource | 402 |
| `FORBIDDEN` | Authenticated but not authorized | 403 |
| `NOT_FOUND` | Resource not found | 404 |
| `METHOD_NOT_SUPPORTED` | Method not supported | 405 |
| `TIMEOUT` | Request timeout | 408 |
| `CONFLICT` | Resource conflict | 409 |
| `PRECONDITION_FAILED` | Precondition failed | 412 |
| `PAYLOAD_TOO_LARGE` | Request too large | 413 |
| `UNPROCESSABLE_CONTENT` | Valid syntax but cannot process | 422 |
| `TOO_MANY_REQUESTS` | Rate limit exceeded | 429 |
| `INTERNAL_SERVER_ERROR` | Unexpected server error | 500 |
# HTTP Router (/docs/server/http)
import { InfoIcon } from "lucide-react"
This guide explores how to build HTTP endpoints using cRPC HTTP router. You'll learn to create typed REST APIs with the fluent builder API, implement schema validation with Zod, handle CORS, and integrate with TanStack Query on the client.
Demo [#demo]
We'll build a todo API with full CRUD operations. The API has typed endpoints for listing, creating, updating, and deleting todos. On the client, we use TanStack Query for data fetching and cache invalidation.
}>
**Note:** For real-time subscriptions, use [queries and mutations](/docs/server/procedures) instead - they're reactive and handle caching automatically via WebSocket. HTTP endpoints are best for REST APIs consumed by external clients, or when you need standard HTTP semantics.
```tsx showLineNumbers title="components/todo-list.tsx"
'use client';
import { useMutation, useQueryClient, useSuspenseQuery } from '@tanstack/react-query';
import { useCRPC } from '@/lib/convex/crpc';
export function TodoList() {
const crpc = useCRPC();
const queryClient = useQueryClient();
// GET /api/todos?limit=10
const { data: todos } = useSuspenseQuery(
crpc.http.todos.list.queryOptions({ searchParams: { limit: '10' } })
);
// POST /api/todos → useMutation
// mutationOptions(opts?) - merged TanStack + client options
const createTodo = useMutation(
crpc.http.todos.create.mutationOptions({
onSuccess: () => {
queryClient.invalidateQueries(crpc.http.todos.list.queryFilter());
},
})
);
return (
{todos.map(todo => (
{todo.title}
))}
{/* JSON body at root level (tRPC-style) */}
);
}
```
Approach [#approach]
The cRPC HTTP router provides a **tRPC-style fluent API** for building typed HTTP endpoints. This approach gives you:
* **End-to-end type safety** from server to client via TypeScript inference.
* **Zod validation** for request body, path params, and search params.
* **Automatic OpenAPI-style routing** with path parameters (`:id`).
* **TanStack Query integration** via `queryOptions` and `mutationOptions`.
* **RSC prefetching** with `prefetch()` and `preloadQuery()`.
| Use Case | Approach |
| ------------------------- | ---------------------------- |
| REST APIs (typed) | cRPC HTTP Router ✅ |
| Browser/React | cRPC HTTP + TanStack Query ✅ |
| Next.js RSC | cRPC HTTP + prefetch ✅ |
| Webhooks (Stripe, GitHub) | Traditional HTTP |
| External integrations | Traditional HTTP |
Anatomy [#anatomy]
Here's a basic example of an HTTP endpoint using cRPC with the fluent builder API.
```ts title="convex/routers/health.ts" showLineNumbers {1-7}
import * as z from 'zod';
import { publicRoute } from '../lib/crpc';
export const health = publicRoute
.get('/api/health') // HTTP method + path
.output(z.object({ status: z.string(), timestamp: z.number() })) // Response schema
.query(async () => ({ // Handler
status: 'ok',
timestamp: Date.now(),
}));
```
The fluent API chains methods to build the endpoint:
1. **Route builder** (`publicRoute` or `authRoute`) - determines authentication requirements
2. **HTTP method** (`.get()`, `.post()`, `.patch()`, `.delete()`) - sets the method and path
3. **Validation** (`.input()`, `.params()`, `.searchParams()`, `.output()`) - Zod schemas
4. **Handler** (`.query()` or `.mutation()`) - the function that processes the request
Setup [#setup]
Create Route Builders [#create-route-builders]
Export HTTP route builders from your cRPC setup. These determine whether endpoints require authentication.
```ts title="convex/lib/crpc.ts" showLineNumbers
import { CRPCError, initCRPC } from 'kitcn/server';
import type { DataModel } from '../functions/_generated/dataModel';
const c = initCRPC
.dataModel()
.context({ /* your context setup */ })
.create({ /* your options */ });
// Public HTTP route - no auth required
export const publicRoute = c.httpAction;
// Auth HTTP route - verifies JWT and adds userId to context
export const authRoute = c.httpAction.use(async ({ ctx, next }) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) {
throw new CRPCError({ code: 'UNAUTHORIZED' });
}
return next({
ctx: { ...ctx, userId: identity.subject },
});
});
// Router factory for grouping endpoints
export const router = c.router;
```
Route Builder Options [#route-builder-options]
| Builder | Authentication | Use Case |
| ----------------- | -------------- | --------------------------- |
| `publicRoute` | None | Public APIs, health checks |
| `authRoute` | Required | User-specific operations |
| Custom middleware | Conditional | Role-based access, API keys |
Defining Routes [#defining-routes]
GET Endpoints [#get-endpoints]
Use `.get()` with `.query()` for read operations. GET endpoints cannot have a request body — see the [health check example above](#anatomy).
Search Parameters [#search-parameters]
Use `.searchParams()` for query string validation. Values come as strings, so use `z.coerce` for type conversion.
```ts title="convex/routers/todos.ts" showLineNumbers {5-8,11}
import * as z from 'zod';
import { publicRoute } from '../lib/crpc';
import { createTodosCaller } from '../functions/generated/todos.runtime';
export const list = publicRoute
.get('/api/todos')
.searchParams(z.object({
limit: z.coerce.number().optional().default(10),
offset: z.coerce.number().optional().default(0),
}))
.output(z.array(todoSchema))
.query(async ({ ctx, searchParams }) => {
const caller = createTodosCaller(ctx);
return caller.list({
limit: searchParams.limit,
offset: searchParams.offset,
});
});
```
}>
**Note:** Use `z.coerce.number()` instead of `z.number()` for search params since URL query strings are always strings. The coerce transformer handles the conversion.
Path Parameters [#path-parameters]
Use `.params()` for URL path parameters. Define placeholders with `:paramName` in the path.
```ts title="convex/routers/todos.ts" showLineNumbers {5-6,9}
import * as z from 'zod';
import { publicRoute } from '../lib/crpc';
import { createTodosCaller } from '../functions/generated/todos.runtime';
export const get = publicRoute
.get('/api/todos/:id')
.params(z.object({ id: z.string() }))
.output(todoSchema.nullable())
.query(async ({ ctx, params }) => {
const caller = createTodosCaller(ctx);
return caller.get({ id: params.id });
});
```
POST Endpoints [#post-endpoints]
Use `.post()` with `.input()` and `.mutation()` for create operations.
```ts title="convex/routers/todos.ts" showLineNumbers {5-9,12-16}
import * as z from 'zod';
import { authRoute } from '../lib/crpc';
import { createTodoInternalCaller } from '../functions/generated/todoInternal.runtime';
export const create = authRoute
.post('/api/todos')
.input(z.object({
title: z.string().min(1, 'Title is required'),
description: z.string().optional(),
}))
.output(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const caller = createTodoInternalCaller(ctx);
const id = await caller.create({
userId: ctx.userId,
...input,
});
return { id };
});
```
PATCH Endpoints [#patch-endpoints]
Use `.patch()` for partial updates. Combine `.params()` for the resource ID and `.input()` for the update data.
```ts title="convex/routers/todos.ts" showLineNumbers {5-11,14-18}
import * as z from 'zod';
import { authRoute } from '../lib/crpc';
import { createTodoInternalCaller } from '../functions/generated/todoInternal.runtime';
export const update = authRoute
.patch('/api/todos/:id')
.params(z.object({ id: z.string() }))
.input(z.object({
title: z.string().optional(),
completed: z.boolean().optional(),
}))
.output(z.object({ success: z.boolean() }))
.mutation(async ({ ctx, params, input }) => {
const caller = createTodoInternalCaller(ctx);
await caller.update({
id: params.id,
...input,
});
return { success: true };
});
```
DELETE Endpoints [#delete-endpoints]
Use `.delete()` for resource deletion. Typically only needs `.params()` for the resource ID.
```ts title="convex/routers/todos.ts" showLineNumbers {5-8,11-13}
import * as z from 'zod';
import { authRoute } from '../lib/crpc';
import { createTodoInternalCaller } from '../functions/generated/todoInternal.runtime';
export const deleteTodo = authRoute
.delete('/api/todos/:id')
.params(z.object({ id: z.string() }))
.output(z.object({ success: z.boolean() }))
.mutation(async ({ ctx, params }) => {
const caller = createTodoInternalCaller(ctx);
await caller.deleteTodo({ id: params.id });
return { success: true };
});
```
HTTP Method Summary [#http-method-summary]
| Method | Builder | Use Case | Has Body |
| ------ | ---------------------- | ----------------- | -------- |
| GET | `.get().query()` | Read operations | No |
| POST | `.post().mutation()` | Create operations | Yes |
| PATCH | `.patch().mutation()` | Partial updates | Yes |
| DELETE | `.delete().mutation()` | Delete operations | No |
Routers [#routers]
Group related endpoints into routers for better organization and code splitting.
Creating a Router [#creating-a-router]
```ts title="convex/routers/todos.ts" showLineNumbers {18-24}
import * as z from 'zod';
import { router, publicRoute, authRoute } from '../lib/crpc';
const todoSchema = z.object({
id: z.string(),
title: z.string(),
completed: z.boolean(),
description: z.string().optional(),
});
const list = publicRoute.get('/api/todos')/* ... */;
const get = publicRoute.get('/api/todos/:id')/* ... */;
const create = authRoute.post('/api/todos')/* ... */;
const update = authRoute.patch('/api/todos/:id')/* ... */;
const deleteTodo = authRoute.delete('/api/todos/:id')/* ... */;
export const todosRouter = router({
list,
get,
create,
update,
delete: deleteTodo,
});
```
Combining Routers [#combining-routers]
Compose routers into a single app router for registration.
```ts title="convex/functions/http.ts" showLineNumbers {10-13}
import { router } from '../lib/crpc';
import { health } from '../routers/health';
import { todosRouter } from '../routers/todos';
import { usersRouter } from '../routers/users';
// Combine all routes into app router
export const httpRouter = router({
health,
todos: todosRouter,
users: usersRouter,
});
```
Registration [#registration]
The HTTP router uses [Hono](https://hono.dev/) for routing and middleware. This provides CORS handling, middleware chains, and a clean API for building HTTP endpoints.
```ts title="convex/functions/http.ts" showLineNumbers {1-6,8-18,20-24}
import { authMiddleware } from 'kitcn/auth/http';
import { createHttpRouter } from 'kitcn/server';
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { router } from '../lib/crpc';
import { getAuth } from './generated/auth';
import { health } from '../routers/health';
import { todosRouter } from '../routers/todos';
const app = new Hono();
// CORS for API routes (auth + cRPC)
app.use(
'/api/*',
cors({
origin: process.env.SITE_URL!,
allowHeaders: ['Content-Type', 'Authorization', 'Better-Auth-Cookie'],
exposeHeaders: ['Set-Better-Auth-Cookie'],
credentials: true,
})
);
// Better Auth middleware
app.use(authMiddleware(getAuth));
// cRPC router
export const httpRouter = router({
health,
todos: todosRouter,
});
export default createHttpRouter(app, httpRouter);
```
Key Components [#key-components]
| Component | Purpose |
| ----------------------------------- | ------------------------------------------ |
| `Hono` | Route handling, middleware, CORS |
| `authMiddleware(getAuth)` | Better Auth routes middleware |
| `createHttpRouter(app, httpRouter)` | Creates Convex HttpRouter with Hono + cRPC |
CORS [#cors]
Configure Cross-Origin Resource Sharing using Hono's `cors()` middleware.
```ts title="convex/functions/http.ts" showLineNumbers {1,5-12}
import { cors } from 'hono/cors';
const app = new Hono();
app.use(
'/api/*',
cors({
origin: ['https://myapp.com', 'http://localhost:3000'],
allowHeaders: ['Content-Type', 'Authorization'],
credentials: true,
})
);
```
}>
**Note:** For Better Auth integration, include `'Better-Auth-Cookie'` in `allowHeaders` and `'Set-Better-Auth-Cookie'` in `exposeHeaders` to ensure auth cookies work correctly. See [CORS Options](#cors-options) in the API Reference for all available parameters.
Error Handling [#error-handling]
Throw `CRPCError` to return appropriate HTTP status codes. The error code maps to standard HTTP status codes.
```ts title="convex/routers/todos.ts" showLineNumbers {10-14}
import { CRPCError } from 'kitcn/server';
export const get = publicRoute
.get('/api/todos/:id')
.params(z.object({ id: z.string() }))
.output(todoSchema)
.query(async ({ ctx, params }) => {
const caller = createTodosCaller(ctx);
const todo = await caller.get({ id: params.id });
if (!todo) {
throw new CRPCError({
code: 'NOT_FOUND',
message: 'Todo not found',
});
}
return todo;
});
```
Error Code Reference [#error-code-reference]
| Code | HTTP Status | Use Case |
| ----------------------- | ----------- | ----------------------------------- |
| `BAD_REQUEST` | 400 | Invalid request format |
| `UNAUTHORIZED` | 401 | Missing or invalid authentication |
| `FORBIDDEN` | 403 | Authenticated but not authorized |
| `NOT_FOUND` | 404 | Resource doesn't exist |
| `CONFLICT` | 409 | Resource conflict (e.g., duplicate) |
| `UNPROCESSABLE_CONTENT` | 422 | Validation failed |
| `TOO_MANY_REQUESTS` | 429 | Rate limit exceeded |
| `INTERNAL_SERVER_ERROR` | 500 | Unexpected server error |
Validation Errors [#validation-errors]
Zod validation failures automatically return `400 Bad Request` with error details.
```json
{
"error": {
"code": "BAD_REQUEST",
"message": "Validation failed",
"details": [
{ "path": ["title"], "message": "String must contain at least 1 character(s)" }
]
}
}
```
Custom Responses [#custom-responses]
cRPC handlers receive `c` (Hono Context) for custom responses, headers, and raw request access. This lets you return non-JSON responses like file downloads, redirects, or streaming data.
File Downloads [#file-downloads]
```ts title="convex/routers/todos.ts" showLineNumbers {5-7,9-15}
export const download = authRoute
.get('/api/todos/export/:format')
.params(z.object({ format: z.enum(['json', 'csv']) }))
.query(async ({ ctx, params, c }) => {
const caller = createTodosCaller(ctx);
const todos = await caller.list({ limit: 100 });
c.header('Content-Disposition', `attachment; filename="todos.${params.format}"`);
c.header('Cache-Control', 'no-cache');
if (params.format === 'csv') {
const csv = [
'id,title,completed',
...todos.map((t) => `${t.id},${t.title},${t.completed}`),
].join('\n');
return c.text(csv);
}
return c.json({ todos });
});
```
Redirects [#redirects]
```ts title="convex/routers/public.ts" showLineNumbers {4}
export const redirect = publicRoute
.get('/api/old-path')
.query(async ({ c }) => c.redirect('/api/new-path', 301));
```
See [Hono Context Methods](#hono-context-methods) in the API Reference for all available `c` methods.
Streaming [#streaming]
For Server-Sent Events (SSE) and AI streaming, use Hono's streaming helpers.
Server-Sent Events [#server-sent-events]
```ts title="convex/routers/stream.ts" showLineNumbers {1,6-8,10-16}
import { streamText } from 'hono/streaming';
import { createDataCaller } from '../functions/generated/data.runtime';
export const events = publicRoute
.get('/api/stream')
.query(async ({ ctx, c }) => {
c.header('Content-Type', 'text/event-stream');
c.header('Cache-Control', 'no-cache');
c.header('Connection', 'keep-alive');
return streamText(c, async (stream) => {
for (let i = 0; i < 10; i++) {
const caller = createDataCaller(ctx);
const data = await caller.getChunk({ index: i });
await stream.write(`data: ${JSON.stringify(data)}\n\n`);
await stream.sleep(1000);
}
});
});
```
AI Streaming [#ai-streaming]
```ts title="convex/routers/ai.ts" showLineNumbers {1-2,9-12}
import { stream } from 'hono/streaming';
import { createAiCaller } from '../functions/generated/ai.runtime';
export const chat = publicRoute
.post('/api/ai/stream')
.input(z.object({ prompt: z.string() }))
.mutation(async ({ ctx, input, c }) => {
c.header('Content-Type', 'text/event-stream');
c.header('Cache-Control', 'no-cache');
const ai = createAiCaller(ctx);
const aiStream = await ai.actions.streamResponse({
prompt: input.prompt,
});
return stream(c, async (stream) => {
await stream.pipe(aiStream);
});
});
```
Webhooks [#webhooks]
Handle external service callbacks like Stripe or GitHub using cRPC routes. Always verify signatures before processing.
```ts title="convex/routers/webhooks.ts" showLineNumbers {3-5,15,20-23,27-32}
import { CRPCError } from 'kitcn/server';
import { publicRoute } from '../lib/crpc';
import { createPaymentsCaller } from '../functions/generated/payments.runtime';
import { createStripeCaller } from '../functions/generated/stripe.runtime';
import { createSubscriptionsCaller } from '../functions/generated/subscriptions.runtime';
export const stripe = publicRoute
.post('/webhooks/stripe')
.mutation(async ({ ctx, c }) => {
// 1. Check signature header
const signature = c.req.header('stripe-signature');
if (!signature) {
throw new CRPCError({ code: 'BAD_REQUEST', message: 'No signature' });
}
const body = await c.req.text();
// 2. Verify signature
const stripeCaller = createStripeCaller(ctx);
const isValid = await stripeCaller.actions.verify({
body,
signature,
});
if (!isValid) {
throw new CRPCError({ code: 'BAD_REQUEST', message: 'Invalid signature' });
}
// 3. Process the webhook
const payments = createPaymentsCaller(ctx);
const subscriptions = createSubscriptionsCaller(ctx);
const event = JSON.parse(body);
switch (event.type) {
case 'payment_intent.succeeded':
await payments.markPaid({
paymentIntentId: event.data.object.id,
});
break;
case 'customer.subscription.deleted':
await subscriptions.cancel({
subscriptionId: event.data.object.id,
});
break;
}
return c.text('OK', 200);
});
```
React Client [#react-client]
Access HTTP endpoints on the client via `crpc.http.*`. The proxy uses a **hybrid API** that combines the best of both worlds: tRPC-style JSON body at root level with explicit `params`/`searchParams`/`form` for URL and form data.
The client uses a structured input format that separates concerns clearly. All options go in args (1st param). JSON body fields are spread at the root level (tRPC-style), while URL-related data and client options use explicit keys. See [Input Args](#input-args) in the API Reference for the full property list.
Setup [#setup-1]
The HTTP proxy is automatically available when you set up cRPC.
```tsx showLineNumbers title="lib/convex/crpc.tsx"
import { api } from '@convex/api';
import { createCRPCContext } from 'kitcn/react';
export const { CRPCProvider, useCRPC } = createCRPCContext({
api,
convexSiteUrl: process.env.NEXT_PUBLIC_CONVEX_SITE_URL!, // Required for HTTP
});
```
If you customize wire serialization, pass `transformer` here too. `Date` stays always-on (`$date`), and your custom transformer is additive.
GET Requests [#get-requests]
Use `queryOptions` with TanStack Query's `useQuery` or `useSuspenseQuery`.
```tsx showLineNumbers title="components/todo-list.tsx" showLineNumbers {8-10}
import { useSuspenseQuery } from '@tanstack/react-query';
import { useCRPC } from '@/lib/convex/crpc';
export function TodoList() {
const crpc = useCRPC();
// GET /api/todos?limit=10 - searchParams explicit
const { data: todos } = useSuspenseQuery(
crpc.http.todos.list.queryOptions({ searchParams: { limit: '10' } })
);
// GET /api/todos/:id - path params explicit
const { data: todo } = useSuspenseQuery(
crpc.http.todos.get.queryOptions({ params: { id: todoId } })
);
return (
{todos.map(todo => (
{todo.title}
))}
);
}
```
`queryOptions` ships a 30 second `staleTime`, so an RSC-prefetched entry hydrates without an immediate refetch on mount. `refetchOnMount` stays at TanStack's default, so a route invalidated while unmounted still refetches when it comes back. Override either per call:
```ts
crpc.http.todos.list.queryOptions(
{ searchParams: { limit: '10' } },
{ staleTime: 0 }
);
```
One-Time Fetch [#one-time-fetch]
For one-time fetches like exports or downloads, use `mutationOptions` with `useMutation`. This gives you `isPending` state and `onSuccess`/`onError` callbacks without caching:
```tsx showLineNumbers {1-3}
const exportTodos = useMutation(
crpc.http.todos.export.mutationOptions()
);
// Trigger export with loading state
exportTodos.mutate({ params: { format: 'csv' } });
```
For imperative fetching in event handlers or effects, use the vanilla client:
```tsx showLineNumbers {5-6,9-10}
import { useQueryClient } from '@tanstack/react-query';
import { useCRPC, useCRPCClient } from '@/lib/convex/crpc';
const client = useCRPCClient();
const todos = await client.http.todos.list.query();
await client.http.todos.create.mutate({ title: 'New todo' });
// For cache-aware fetches in render context
const crpc = useCRPC();
const queryClient = useQueryClient();
const todos = await queryClient.fetchQuery(crpc.http.todos.list.queryOptions());
```
staticQueryOptions [#staticqueryoptions]
For prefetching in event handlers, use `staticQueryOptions`. Unlike `queryOptions`, it doesn't use hooks internally, so you can call it anywhere:
```tsx showLineNumbers {6-8}
import { useQueryClient } from '@tanstack/react-query';
import { useCRPC } from '@/lib/convex/crpc';
const crpc = useCRPC();
const queryClient = useQueryClient();
// Prefetch on hover - works in event handlers
const handleMouseEnter = () => {
queryClient.prefetchQuery(crpc.http.todos.list.staticQueryOptions());
};
// Fetch on click
const handleClick = async () => {
const todos = await queryClient.fetchQuery(
crpc.http.todos.list.staticQueryOptions()
);
};
```
}>
**Note:** `staticQueryOptions` doesn't include reactive auth state. Auth is handled at execution time by the `queryFn` - if the user isn't authenticated for a protected endpoint, it will throw an `UNAUTHORIZED` error.
Comparison [#comparison]
| Method | Context | Caching | Use Case |
| ---------------------------------- | ----------- | ---------- | --------------------------- |
| `client.http.*.query()` | Anywhere | None | Direct calls without cache |
| `crpc.http.*.queryOptions()` | Render only | Uses cache | Components (uses hooks) |
| `crpc.http.*.staticQueryOptions()` | Anywhere | Uses cache | Prefetching, event handlers |
}>
**When to use which:**
* Use `queryOptions` in components for reactive auth handling
* Use `staticQueryOptions` for prefetching in event handlers
* Use `client.http.*.query()` when you don't need TanStack Query caching
POST/PATCH/DELETE Requests [#postpatchdelete-requests]
Use `mutationOptions` with TanStack Query's `useMutation`.
```tsx title="components/create-todo.tsx" showLineNumbers {9-17,22}
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useCRPC } from '@/lib/convex/crpc';
export function CreateTodo() {
const crpc = useCRPC();
const queryClient = useQueryClient();
const [title, setTitle] = useState('');
// POST /api/todos
// mutationOptions(opts?) - merged TanStack + client options
const createTodo = useMutation(
crpc.http.todos.create.mutationOptions({
onSuccess: () => {
queryClient.invalidateQueries(crpc.http.todos.list.queryFilter());
setTitle('');
},
})
);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
// JSON body at root level (tRPC-style)
createTodo.mutate({ title });
};
return (
);
}
```
File Uploads with FormData [#file-uploads-with-formdata]
For typed file uploads, define a `.form()` schema on your endpoint:
```ts showLineNumbers title="convex/routers/files.ts"
export const upload = authRoute
.post('/api/files/upload')
.form(z.object({
file: z.instanceof(File),
title: z.string().optional(),
tags: z.array(z.string()).optional(),
}))
.mutation(async ({ ctx, c, form }) => {
// form.file is typed as File, form.title as string | undefined
const storageId = await ctx.storage.store(form.file);
return c.json({ storageId, filename: form.file.name });
});
```
The client then gets typed `form` args:
```tsx showLineNumbers {4-6,11-17}
const uploadFile = useMutation(
crpc.http.files.upload.mutationOptions()
);
// Single file - typed from .form() schema
uploadFile.mutate({ form: { file: selectedFile } });
// Mixed form data - all fields typed
uploadFile.mutate({
form: {
title: 'My Document',
file: selectedFile,
tags: ['work', 'important'],
}
});
```
Cache Invalidation [#cache-invalidation]
Use `queryFilter()` to invalidate related queries after mutations.
```tsx showLineNumbers {5-9,14-18}
const crpc = useCRPC();
const queryClient = useQueryClient();
const deleteTodo = useMutation(
crpc.http.todos.delete.mutationOptions({
onSuccess: () => {
queryClient.invalidateQueries(crpc.http.todos.list.queryFilter());
},
})
);
const updateTodo = useMutation(
crpc.http.todos.update.mutationOptions({
onSuccess: (_, vars) => {
// vars is HttpInputArgs - access params.id
queryClient.invalidateQueries(crpc.http.todos.list.queryFilter());
queryClient.invalidateQueries(
crpc.http.todos.get.queryFilter({ params: { id: vars.params?.id } })
);
},
})
);
// PATCH with path params + JSON body at root
updateTodo.mutate({ params: { id: todoId }, completed: true });
// DELETE with path params
deleteTodo.mutate({ params: { id: todoId } });
```
Cache Keys [#cache-keys]
`queryKey()` returns the exact key an entry is stored under — the same key `queryOptions()` and RSC prefetching use. Pass it to `getQueryData`/`setQueryData`, which match by exact key.
```tsx showLineNumbers {4-5,8-9}
const crpc = useCRPC();
const queryClient = useQueryClient();
const queryKey = crpc.http.todos.list.queryKey();
// => ['httpQuery', 'todos.list', {}]
const data = queryClient.getQueryData(queryKey);
queryClient.setQueryData(queryKey, newData);
// Args are part of the key
crpc.http.todos.get.queryKey({ params: { id: todoId } });
// => ['httpQuery', 'todos.get', { params: { id: todoId } }]
```
Reach for `queryFilter()` instead when you want every args variant of a route.
Custom Headers and Fetch Options [#custom-headers-and-fetch-options]
You can pass custom headers, a custom `fetch` function, or standard `RequestInit` options directly in your args. This keeps everything in one place and makes per-request customization straightforward.
For queries, add client options alongside your other args:
```tsx showLineNumbers {4-5}
// Query with custom headers
const { data } = useSuspenseQuery(
crpc.http.todos.list.queryOptions({
searchParams: { limit: '10' },
headers: { 'X-Custom-Header': 'value' },
})
);
```
For mutations, you can set options at the `mutationOptions` level (for defaults) or pass them per-call in `mutate()`. Per-call options are useful for things like auth tokens or abort signals:
```tsx showLineNumbers {9-13}
const controller = new AbortController();
const createTodo = useMutation(
crpc.http.todos.create.mutationOptions({
onSuccess: () => console.log('Created!'),
})
);
// Per-call client options in mutate args
createTodo.mutate({
title: 'New Todo',
headers: { 'Authorization': `Bearer ${token}` },
init: { signal: controller.signal },
});
```
See [Client API Reference](#client-api-reference) in the API Reference below for all available methods.
}>
**Note:** For more React patterns including loading states, error handling, and conditional queries, see [Queries](/docs/react/queries) and [Mutations](/docs/react/mutations).
RSC Prefetching [#rsc-prefetching]
Prefetch HTTP endpoints in Server Components for instant client hydration.
Basic Prefetch [#basic-prefetch]
Use `prefetch()` for fire-and-forget data loading.
```tsx title="app/todos/page.tsx" showLineNumbers {6-7,10}
import { crpc, HydrateClient, prefetch } from '@/lib/convex/rsc';
import { TodoList } from './todo-list';
export default async function TodosPage() {
// Fire-and-forget prefetch - doesn't block rendering
prefetch(crpc.http.health.queryOptions());
prefetch(crpc.http.todos.list.queryOptions({ searchParams: { limit: '10' } }));
return (
);
}
```
Awaited Prefetch [#awaited-prefetch]
Use `preloadQuery()` when you need the data on the server (e.g., for conditionals or metadata).
```tsx title="app/todos/[id]/page.tsx" showLineNumbers {9-12,14-16}
import { crpc, HydrateClient, preloadQuery } from '@/lib/convex/rsc';
import { notFound } from 'next/navigation';
import { TodoDetail } from './todo-detail';
export default async function TodoPage({
params,
}: { params: Promise<{ id: string }> }) {
const { id } = await params;
// Await the data - available on server
// Path params explicit: { params: { ... } }
const todo = await preloadQuery(
crpc.http.todos.get.queryOptions({ params: { id } })
);
if (!todo) {
notFound();
}
return (
);
}
```
Auth-Aware Prefetch [#auth-aware-prefetch]
Use `skipUnauth` to skip auth-required queries without errors when not logged in.
```tsx title="app/layout.tsx" showLineNumbers {5-8}
import { crpc, HydrateClient, prefetch } from '@/lib/convex/rsc';
export default async function AppLayout({ children }: { children: React.ReactNode }) {
// Skip if not authenticated - returns null instead of erroring
prefetch(
crpc.http.todos.list.queryOptions(
{ searchParams: { limit: '10' } },
{ skipUnauth: true }
)
);
return {children};
}
```
RSC Pattern Comparison [#rsc-pattern-comparison]
| Pattern | Blocking | Server Access | Client Hydration |
| ---------------- | -------- | ------------- | ---------------- |
| `prefetch()` | No | No | Yes |
| `preloadQuery()` | Yes | Yes | Yes |
}>
**Note:** RSC HTTP prefetching requires `convexSiteUrl` in `getServerQueryClientOptions`. For complete setup including QueryClient configuration, see [Next.js RSC](/docs/nextjs/rsc).
Server-Side Calls [#server-side-calls]
For server-to-server calls outside React (API routes, middleware, cron jobs), use `createContext` with the caller pattern. The caller accesses your cRPC router namespaces directly.
```ts title="app/api/sync/route.ts" showLineNumbers {1,4-5,8,11-12}
import { createContext } from '@/lib/convex/server';
export async function POST(request: Request) {
// Create context with auth headers
const ctx = await createContext({ headers: request.headers });
// Query using the caller (matches your httpRouter structure)
const todos = await ctx.caller.todos.list({ limit: 10 });
// Mutations
if (ctx.isAuthenticated) {
await ctx.caller.todos.create({ title: 'New task' });
}
return Response.json({ todos });
}
```
}>
**Note:** The caller provides full type safety and uses your cRPC router definitions. See [Server-Side Calls](/docs/server/server-side-calls) for complete setup.
API Reference [#api-reference]
CORS Options [#cors-options]
Hono's `cors()` middleware accepts these options:
| Option | Type | Default | Description |
| --------------- | -------------------- | --------------------------------------------------- | ------------------------------------ |
| `origin` | `string \| string[]` | `'*'` | Allowed origin URLs or `'*'` for any |
| `allowMethods` | `string[]` | `['GET', 'HEAD', 'PUT', 'POST', 'DELETE', 'PATCH']` | Allowed HTTP methods |
| `allowHeaders` | `string[]` | `[]` | Allowed request headers |
| `exposeHeaders` | `string[]` | `[]` | Headers exposed to the browser |
| `credentials` | `boolean` | `false` | Allow cookies and auth headers |
| `maxAge` | `number` | - | Preflight cache duration in seconds |
Hono Context Methods [#hono-context-methods]
The `c` (Hono Context) object available in all handlers:
| Method | Description |
| -------------------------- | ---------------------------- |
| `c.json(data)` | Return JSON response |
| `c.text(str)` | Return text response |
| `c.redirect(url, status?)` | Return redirect response |
| `c.header(name, value)` | Set response header |
| `c.req.header(name)` | Get request header |
| `c.req.text()` | Get raw request body as text |
Input Args [#input-args]
Structured input format for all client calls. All options go in the first argument:
| Property | Type | Description |
| -------------- | --------------------------------------- | ----------------------------------------- |
| `params` | `Record` | Path parameters (`:id` in `/users/:id`) |
| `searchParams` | `Record` | Query string parameters |
| `form` | `z.infer` | Typed FormData (if server uses `.form()`) |
| `fetch` | `typeof fetch` | Custom fetch function (per-call override) |
| `init` | `RequestInit` | Standard fetch options (highest priority) |
| `headers` | `Record \| (() => ...)` | Request headers (incl. auth, cookies) |
| `[key]` | `unknown` | JSON body fields at root level |
Client API Reference [#client-api-reference]
Methods available on each route proxy (`crpc.http.*.*`):
| Method | Signature | Description |
| ----------------- | --------------------- | ---------------------------------------------------------------------------------------------------------- |
| `queryOptions` | `(args?, queryOpts?)` | Options for `useQuery`/`useSuspenseQuery`. `args` includes client opts, `queryOpts` is TanStack Query only |
| `mutationOptions` | `(mutationOpts?)` | Options for `useMutation`. `mutationOpts` is TanStack Mutation only, client opts go in `mutate(args)` |
| `queryKey` | `(args?)` | Exact cache key for these args — for `getQueryData`/`setQueryData` |
| `queryFilter` | `(args?, filters?)` | Filter for `invalidateQueries`, etc. Omit `args` to match every variant |
Next Steps [#next-steps]
# Metadata (/docs/server/metadata)
import { InfoIcon } from "lucide-react"
Client Metadata (Codegen) [#client-metadata-codegen]
The CLI generates `convex/shared/api.ts` automatically. Each public procedure leaf includes metadata plus a `functionRef`:
```ts title="convex/shared/api.ts" showLineNumbers
// Auto-generated by `kitcn dev`
export const api = {
user: {
list: {
type: 'query',
auth: 'optional',
functionRef: /* Convex FunctionReference */,
},
create: {
type: 'mutation',
auth: 'required',
ratelimit: 'user/create',
functionRef: /* Convex FunctionReference */,
},
},
admin: {
list: {
type: 'query',
auth: 'required',
role: 'admin',
functionRef: /* Convex FunctionReference */,
},
},
} as const;
```
}>
**Important:** Never create `convex/shared/api.ts` manually. It's generated and should not contain secrets.
Define Meta Type [#define-meta-type]
Chain `.meta<{ ... }>()` during [initialization](/docs/server/setup):
```ts title="convex/lib/crpc.ts" showLineNumbers {3-7,11}
import { initCRPC } from 'kitcn/server';
const c = initCRPC
.dataModel()
.meta<{
auth?: 'optional' | 'required';
role?: 'admin';
ratelimit?: string;
}>()
.create();
```
Now TypeScript knows what metadata properties are valid.
Set Procedure Metadata [#set-procedure-metadata]
Use `.meta()` to set metadata on procedures:
```ts title="convex/functions/admin.ts" showLineNumbers {2,7}
export const adminOnly = authQuery
.meta({ role: 'admin' })
.query(async ({ ctx }) => {
return ctx.orm.query.user.findMany({ limit: 100 });
});
export const createSession = authMutation
.meta({ ratelimit: 'session/create' })
.input(z.object({ token: z.string() }))
.mutation(async ({ ctx, input }) => {
const [row] = await ctx.orm
.insert(session)
.values({ token: input.token, userId: ctx.userId })
.returning({ id: session.id });
return row.id;
});
```
Access in Middleware [#access-in-middleware]
Access `meta` in middleware to customize behavior:
```ts title="convex/lib/crpc.ts" showLineNumbers {1,7}
const roleMiddleware = c.middleware(({ ctx, meta, next }) => {
if (meta.role === 'admin' && !ctx.user?.isAdmin) {
throw new CRPCError({ code: 'FORBIDDEN' });
}
return next({ ctx });
});
const ratelimit = RatelimitPlugin.configure({
buckets: ratelimitBuckets,
getBucket: ({ meta }: { meta: { ratelimit?: string } }) =>
meta.ratelimit ?? 'default',
// ...
});
```
The role middleware reads `meta.role`, and the ratelimit plugin reads `meta.ratelimit` through `getBucket(...)`.
Default Meta [#default-meta]
You can set default metadata values in `create()`. All procedures will start with these values:
```ts title="convex/lib/crpc.ts" showLineNumbers {4}
const c = initCRPC
.dataModel()
.meta<{
auth?: 'optional' | 'required';
role?: 'admin';
ratelimit?: string;
}>()
.create({
defaultMeta: { auth: 'optional' },
});
// All procedures start with { auth: 'optional' }
```
Chaining (Shallow Merge) [#chaining-shallow-merge]
Chaining `.meta()` calls shallow merges values. Each call adds to or overrides the previous metadata:
```ts showLineNumbers {5,9,13}
export const publicQuery = c.query;
// Meta: { auth: 'optional' } (from defaultMeta)
export const authQuery = c.query
.meta({ auth: 'required' });
// Meta: { auth: 'required' }
export const adminQuery = authQuery
.meta({ role: 'admin' });
// Meta: { auth: 'required', role: 'admin' }
export const ratelimitedAdmin = adminQuery
.meta({ ratelimit: 'admin/heavy' });
// Meta: { auth: 'required', role: 'admin', ratelimit: 'admin/heavy' }
```
This makes it easy to build procedure variants with progressively more configuration.
Common Patterns [#common-patterns]
Here are common metadata patterns.
Auth Level [#auth-level]
The `auth` metadata controls both server and client behavior:
**Server-side:** Middleware checks authentication
**Client-side:** Query waits for auth loading and skips appropriately
| `auth` value | Server | Client (auth loading) | Client (logged out) |
| ------------ | ------------- | --------------------- | ------------------- |
| (none) | No check | Runs immediately | Runs |
| `'optional'` | User optional | Waits | Runs |
| `'required'` | User required | Waits | Skips |
Use `auth` metadata to distinguish between optional and required authentication:
```ts title="convex/lib/crpc.ts" showLineNumbers {2,8-9}
const c = initCRPC
.dataModel()
.meta<{ auth?: 'optional' | 'required' }>()
.create();
export const optionalAuthQuery = c.query
.meta({ auth: 'optional' })
.use(async ({ ctx, next }) => {
const user = await getSessionUser(ctx);
return next({ ctx: { ...ctx, user } });
});
export const authQuery = c.query
.meta({ auth: 'required' })
.use(async ({ ctx, next }) => {
const user = await getSessionUser(ctx);
if (!user) throw new CRPCError({ code: 'UNAUTHORIZED' });
return next({ ctx: { ...ctx, user } });
});
```
Role-Based Access [#role-based-access]
Using the `roleMiddleware` from [Access in Middleware](#access-in-middleware), build an admin procedure variant:
```ts title="convex/lib/crpc.ts" showLineNumbers {1-2,5-7}
export const adminQuery = authQuery
.meta({ role: 'admin' })
.use(roleMiddleware);
// Usage
export const list = adminQuery
.query(async ({ ctx }) => {
return ctx.orm.query.user.findMany({ limit: 100 });
});
```
Rate Limiting [#rate-limiting]
Apply different rate limits based on metadata:
```ts title="convex/lib/crpc.ts" showLineNumbers {2,5-12,15-16}
const c = initCRPC
.dataModel()
.meta<{ ratelimit?: string }>()
.create();
export const createSession = authMutation
.meta({ ratelimit: 'session/create' })
.use(ratelimit.middleware())
.mutation(async ({ ctx, input }) => { ... });
```
Dev Mode [#dev-mode]
Restrict certain procedures to development only:
```ts title="convex/lib/crpc.ts" showLineNumbers {2,5-12,15}
const c = initCRPC
.dataModel()
.meta<{ dev?: boolean }>()
.create();
const devMiddleware = c.middleware(({ meta, next, ctx }) => {
if (meta.dev && process.env.NODE_ENV === 'production') {
throw new CRPCError({
code: 'FORBIDDEN',
message: 'This function is only available in development',
});
}
return next({ ctx });
});
export const debugQuery = publicQuery
.meta({ dev: true })
.query(async ({ ctx }) => { ... });
```
Next Steps [#next-steps]
# Middlewares (/docs/server/middlewares)
import { InfoIcon } from "lucide-react"
Middleware wraps procedure invocation. It runs before (and optionally after) your handler. The key rule: middleware must call `next()` and return its result.
Authorization [#authorization]
```ts title="convex/lib/crpc.ts" showLineNumbers {2-6,7-9}
export const authQuery = c.query.use(async ({ ctx, next }) => {
const user = await getSessionUser(ctx);
if (!user) {
throw new CRPCError({ code: 'UNAUTHORIZED' });
}
return next({
ctx: { ...ctx, user, userId: user.id },
});
});
```
Now when you use `authQuery`, you're guaranteed to have `ctx.user` and `ctx.userId`:
```ts title="convex/functions/posts.ts" showLineNumbers {3-5}
export const myPosts = authQuery
.query(async ({ ctx }) => {
// ctx.user and ctx.userId are guaranteed to exist
return ctx.orm.query.posts.findMany({
where: { authorId: ctx.userId },
limit: 50,
});
});
```
Middleware Signature [#middleware-signature]
Every middleware receives an object — see [Middleware Parameters](#middleware-parameters) in API Reference for the full property list.
```ts showLineNumbers {2-5}
.use(async ({ ctx, meta, procedure, next, input }) => {
// Do something before
const result = await next({ ctx });
// Do something after (optional)
return result;
})
```
Context Extension [#context-extension]
Middleware can add or transform context properties. The new context is fully type-safe - TypeScript knows exactly what's available.
The `authQuery` [above](#authorization) demonstrates this: it calls `next({ ctx: { ...ctx, user, userId: user.id } })`, and downstream handlers see `ctx.user` typed as `User` (not `User | null`).
Input Access [#input-access]
Middleware placed **after** `.input()` receives typed input. Use it to fetch related data:
```ts showLineNumbers {4-6}
export const queryWithProject = publicQuery
.input(z.object({ projectId: z.string(), name: z.string() }))
.use(async ({ ctx, input, next }) => {
const project = await ctx.orm.query.projects.findFirst({
where: { id: input.projectId },
});
// Add to context, enrich input, or override fields
return next({ ctx: { ...ctx, project }, input: { ...input, name: input.name.trim() } });
})
.query(async ({ ctx, input }) => input.project); // both work
```
Before `.input()`, input is `unknown`. Use `getRawInput()` for raw input before validation.
Using Meta [#using-meta]
Sometimes different procedures need different behavior from the same middleware. That's where metadata comes in.
First, define your meta type when initializing cRPC:
```ts title="convex/lib/crpc.ts" showLineNumbers {1-5,9}
const c = initCRPC
.dataModel()
.meta<{
auth?: 'optional' | 'required';
role?: 'admin';
ratelimit?: string;
}>()
.create();
```
Now middleware can read metadata and act accordingly:
```ts title="convex/lib/crpc.ts" showLineNumbers {2-5}
const roleMiddleware = c.middleware<{ user: { isAdmin: boolean } }>(
({ ctx, meta, next }) => {
if (meta.role === 'admin' && !ctx.user.isAdmin) {
throw new CRPCError({ code: 'FORBIDDEN' });
}
return next({ ctx });
}
);
// Set metadata when building procedure variants
export const adminQuery = authQuery
.meta({ role: 'admin' })
.use(roleMiddleware);
```
Procedure Info [#procedure-info]
Middleware also receives a server-only `procedure` object for logging and tracing.
* `procedure.type` is always available.
* Standard `export const` queries, mutations, and actions infer `procedure.name` automatically when they are built from your app `generated/server` helper, which is the normal setup path.
* Use `.name('module:function')` to override the inferred name or cover unusual export shapes.
* HTTP routes derive `procedure.name`, `procedure.method`, and `procedure.path` from the route automatically.
Chaining Middleware [#chaining-middleware]
Chain multiple `.use()` calls to compose behavior. They execute in order:
```ts title="convex/lib/crpc.ts" showLineNumbers {2-4}
export const authMutation = c.mutation
.meta({ auth: 'required' })
.use(authMiddleware) // 1. Check auth, add user to ctx
.use(roleMiddleware) // 2. Check role if meta.role set
.use(ratelimit.middleware()); // 3. Apply rate limiting
```
}>
**Important:** Order matters! Later middleware can access context from earlier middleware. Put auth first so role checks can access `ctx.user`.
Sharing Middleware [#sharing-middleware]
Queries and mutations have different context types. To share middleware between them, use a loose type constraint:
```ts showLineNumbers {2-3,7}
// ✅ Use loose type constraint for shared middleware
const roleMiddleware = c.middleware