# 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 (
setDraft(event.target.value)} value={draft} />
{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 (
{mode === 'signup' && ( setName(e.target.value)} required /> )} setEmail(e.target.value)} required /> setPassword(e.target.value)} required minLength={8} />
); } ``` }> 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 (
{data.map((session) => ( ))}
); ``` Infinite Scroll [#infinite-scroll] Combine with an intersection observer for infinite scroll: ```tsx showLineNumbers {4-7,9-12,17} function SessionFeed() { const crpc = useCRPC(); const { data, fetchNextPage, hasNextPage, isFetching } = useInfiniteQuery( crpc.session.list.infiniteQueryOptions({}) ); const { bottomRef } = useInfiniteScroll({ fetchNextPage, hasNextPage, isFetching, }); return (
{data.map((session) => ( ))} {isFetching && }
); } ``` 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 (
setTitle(e.target.value)} placeholder="Enter todo title" /> ); } ``` 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(({ ctx, meta, next }) => { // Access user via type assertion const user = (ctx as { user?: { isAdmin?: boolean } }).user; if (meta.role === 'admin' && !user?.isAdmin) { throw new CRPCError({ code: 'FORBIDDEN' }); } return next({ ctx }); }); // Apply to both query and mutation chains export const authQuery = c.query .use(authMiddleware) .use(roleMiddleware); export const authMutation = c.mutation .use(authMiddleware) .use(roleMiddleware); ``` Shared middleware keeps mutation writer types when you apply it to a mutation chain. If the middleware itself performs writes, make it mutation-only: ```ts showLineNumbers {1-2,5-7} import type { MutationCtx } from '../functions/generated/server'; const writeMiddleware = c.middleware(async ({ ctx, next }) => { await ctx.orm.insert(logs).values({ ... }); return next({ ctx }); }); export const authMutation = c.mutation.use(writeMiddleware); ``` Reusable Middleware [#reusable-middleware] Create standalone middleware with `c.middleware()` for reuse across your codebase: ```ts title="convex/lib/crpc.ts" showLineNumbers {2-6,9-15} // Standalone middleware const logMiddleware = c.middleware(async ({ ctx, procedure, next }) => { const start = Date.now(); try { return await next({ ctx }); } finally { console.log(`[${procedure.name ?? procedure.type}] ${Date.now() - start}ms`); } }); export const myPosts = c.query .use(logMiddleware) .query(async ({ ctx }) => { return ctx.orm.query.posts.findMany({ limit: 50, }); }); ``` Extending with .pipe() [#extending-with-pipe] Want to extend existing middleware? Use `.pipe()`: ```ts showLineNumbers {6-11} const authMiddleware = c.middleware(async ({ ctx, next }) => { const user = await getSessionUser(ctx); if (!user) throw new CRPCError({ code: 'UNAUTHORIZED' }); return next({ ctx: { ...ctx, user } }); }); // Extend auth with admin check const adminMiddleware = authMiddleware.pipe(({ ctx, next }) => { if (!ctx.user.isAdmin) { throw new CRPCError({ code: 'FORBIDDEN' }); } return next({ ctx }); }); ``` Common Patterns [#common-patterns] Here are common middleware patterns. Auth Required [#auth-required] With Better Auth, use `getSession()` to retrieve the session and fetch the user: ```ts showLineNumbers {4-6,8-11} 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', message: 'Not authenticated' }); } const user = await ctx.orm.query.user.findFirst({ where: { id: session.userId }, }); if (!user) { throw new CRPCError({ code: 'UNAUTHORIZED', message: 'User not found' }); } return next({ ctx: { ...ctx, user: { id: user.id, ...user }, userId: user.id, }, }); }); ``` Auth Optional [#auth-optional] For queries that work with or without authentication: ```ts showLineNumbers {4-6,12-17} export const optionalAuthQuery = c.query.use(async ({ ctx, next }) => { const session = await getSession(ctx); if (!session) { return next({ ctx: { ...ctx, user: null, userId: null } }); } const user = await ctx.orm.query.user.findFirst({ where: { id: session.userId }, }); if (!user) { return next({ ctx: { ...ctx, user: null, userId: null } }); } return next({ ctx: { ...ctx, user: { id: user.id, ...user }, userId: user.id, }, }); }); ``` Rate Limiting [#rate-limiting] ```ts showLineNumbers {2-6} import { ratelimit } from './plugins/ratelimit/plugin'; export const publicMutation = c.mutation.use(ratelimit.middleware()); ``` Logging [#logging] See `logMiddleware` in [Reusable Middleware](#reusable-middleware) above. Next Steps [#next-steps] API Reference [#api-reference] Middleware Parameters [#middleware-parameters] Every middleware receives an object with these properties: | Property | Description | | ------------- | ---------------------------------------------------------- | | `ctx` | Current context | | `meta` | Procedure metadata | | `next` | Function to call next middleware/handler | | `input` | Validated input (`unknown` before `.input()`, typed after) | | `getRawInput` | Function to get raw input before validation | # Procedures (/docs/server/procedures) import { InfoIcon } from "lucide-react" Input Validation [#input-validation] Use `.input()` to define and validate procedure arguments. The schema runs before your handler, catching invalid data early. ```ts title="convex/functions/user.ts" showLineNumbers {2-3} export const getById = publicQuery .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { return ctx.orm.query.user.findFirst({ where: { id: input.id } }); }); ``` Schema Format [#schema-format] Pass a `z.object()` schema directly. You get all of Zod's validation power - string lengths, email formats, optional fields, and more: ```ts showLineNumbers {2-5} .input(z.object({ name: z.string().min(1).max(100), email: z.string().email(), emailVerified: z.boolean().optional(), })) ``` }> **Note:** Convex requires `z.object()` at the root level. You can't use primitive types like `z.string()` directly. No Input [#no-input] For procedures that take no arguments, simply omit `.input()`: ```ts showLineNumbers export const list = publicQuery .query(async ({ ctx }) => { return ctx.orm.query.user.findMany({ limit: 100 }); }); ``` Input Merging [#input-merging] You can stack `.input()` calls to build complex types. This is especially useful when middleware needs to validate its own input: First, define a procedure that validates and fetches a target user: ```ts title="convex/lib/crpc.ts" showLineNumbers {2-10} const userProcedure = authQuery .input(z.object({ userId: z.string() })) .use(async ({ ctx, input, next }) => { const targetUser = await ctx.orm.query.user.findFirst({ where: { id: input.userId }, }); if (!targetUser) throw new CRPCError({ code: 'NOT_FOUND' }); return next({ ctx: { ...ctx, targetUser } }); }); ``` Now when you extend this procedure, inputs are merged automatically: ```ts title="convex/functions/session.ts" showLineNumbers {2,5} export const list = userProcedure .input(z.object({ limit: z.number().default(10) })) .query(async ({ ctx, input }) => { // input.userId + input.limit both available! return ctx.orm.query.session.findMany({ where: { userId: input.userId }, limit: input.limit, }); }); ``` Output Validation [#output-validation] Use `.output()` to validate return values. This catches bugs where your handler returns unexpected data. ```ts showLineNumbers {3-7} export const getById = publicQuery .input(z.object({ id: z.string() })) .output( z.object({ id: z.string(), name: z.string(), email: z.string(), }) ) .query(async ({ ctx, input }) => { const user = await ctx.orm.query.user.findFirst({ where: { id: input.id } }); if (!user) throw new CRPCError({ code: 'NOT_FOUND' }); return user; }); ``` }> **Note:** For mutations/actions that do not return data, omit `.output(...)`. With no output schema, a `void`/`undefined` response is serialized by Convex as `null`. Output validation is recommended when using [static code generation](https://docs.convex.dev/production/project-configuration#using-static-code-generation-beta). What the Handler Must Return [#what-the-handler-must-return] `.output()` parses the value your handler returned, before it is serialized. The handler returns the schema's **input** type and clients receive its output type, so a schema that transforms applies on the way out. Nothing is substituted for a missing value. A nullable schema needs an explicit `null`: ```ts title="convex/functions/user.ts" // Rejected - the schema is parsed against `undefined` export const displayName = publicQuery .input(z.object({ id: z.string() })) .output(z.string().nullable()) .query(async ({ ctx, input }) => names[input.id]); // Accepted export const displayName = publicQuery .input(z.object({ id: z.string() })) .output(z.string().nullable()) .query(async ({ ctx, input }) => names[input.id] ?? null); ``` Use `.output(z.string().default('...'))` to substitute a value instead. TypeScript rejects most of these before they run. The case it misses is a lookup it believes is always present: under the default `noUncheckedIndexedAccess: false`, an index signature or `array[0]` types as non-`undefined` even when the value is absent. Enable `noUncheckedIndexedAccess` to close that gap. `ctx.orm.query.*.findFirst` returns `| null`, so ORM reads are unaffected. A mismatch throws a `CRPCError` with code `INTERNAL_SERVER_ERROR`. Sanitized structural Zod issues are available in `error.data.ZodError`; custom messages and fields remain in the server-side error cause because they can contain the rejected handler output. }> **Note:** Model an absent value as `.nullable()`, not as a top-level `.optional()`. Convex serializes an `undefined` return as `null` and its returns validator cannot express top-level optionality, so `.output(z.string().optional())` publishes `v.string()` and the deployment rejects the `null` it receives. `.optional()` inside an object is fine. }> **Note:** The low-level `returns:` option on `zCustomQuery`/`zCustomMutation`/ `zCustomAction` does not share this contract — it substitutes `null` for an `undefined` return before parsing. Prefer `.output()`. Handler Methods [#handler-methods] Queries [#queries] Use `.query()` for read-only operations. Queries are cached and support real-time subscriptions - when data changes, clients update automatically. ```ts showLineNumbers {3-5} export const list = publicQuery .input(z.object({ limit: z.number().default(10) })) .query(async ({ ctx, input }) => { return ctx.orm.query.user.findMany({ limit: input.limit }); }); ``` Mutations [#mutations] Use `.mutation()` for write operations. Mutations are transactional - if any part fails, the entire operation rolls back. ```ts showLineNumbers {1-4,6-17} import * as z from 'zod'; import { eq } from 'kitcn/orm'; import { user } from './schema'; export const create = publicMutation .input(z.object({ name: z.string(), email: z.string().email() })) .mutation(async ({ ctx, input }) => { await ctx.orm.insert(user).values(input); }); export const remove = publicMutation .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { await ctx.orm.delete(user).where(eq(user.id, input.id)); }); ``` Actions [#actions] Use `.action()` for side effects and external API calls. Actions can call queries and mutations via [callers](#in-process-procedure-composition). ```ts showLineNumbers {3-6} export const sendWelcomeEmail = publicAction .input(z.object({ to: z.string().email(), name: z.string() })) .action(async ({ ctx, input }) => { await sendEmail({ to: input.to, subject: `Welcome, ${input.name}!` }); return { sent: true }; }); ``` In-Process Procedure Composition [#in-process-procedure-composition] When one procedure needs to call another, use the module runtime factory from `convex/functions/generated/.runtime.ts`. * `createHandler(ctx)` is the default in queries and mutations * `createCaller(ctx)` is for actions and HTTP routes }> Runtime modules export module-named factories: `createCaller` and `createHandler`. ```ts title="convex/functions/teams.ts" showLineNumbers {3,7-9} import * as z from 'zod'; import { privateQuery } from '../lib/crpc'; import { createTeamsHandler } from './generated/teams.runtime'; export const getProjectAndOwner = privateQuery .input(z.object({ projectId: z.string() })) .query(async ({ ctx, input }) => { const handler = createTeamsHandler(ctx); const project = await handler.getProject({ projectId: input.projectId }); const owner = await handler.getOwner({ id: project.ownerId }); return { project, owner }; }); ``` How It Works [#how-it-works] `createCaller(ctx)` detects your context type and dispatches accordingly: * **`QueryCtx` / `MutationCtx`**: invokes the procedure handler directly — same transaction, no Convex round trip. * **`ActionCtx`**: root calls dispatch via `ctx.runQuery` / `ctx.runMutation` automatically (separate transactions). * **`caller.actions.*`**: explicit action dispatch in `ActionCtx` via `ctx.runAction`. * **`caller.schedule.*`**: schedule mutation/action procedures via `ctx.scheduler`. See the [Allowed Call Matrix](#allowed-call-matrix) in API Reference for the full compatibility table. Action and Schedule Namespaces [#action-and-schedule-namespaces] ```ts title="convex/functions/jobs.ts" showLineNumbers {7-8,10-12} const caller = createJobsCaller(ctx); // Direct action call (ActionCtx only) await caller.actions.reindex({ force: true }); // Schedule mutation/action calls (MutationCtx + ActionCtx) await caller.schedule.now.enqueueReport({ reportId: 'r_1' }); await caller.schedule.after(5000).reindex({ force: true }); await caller.schedule.at(Date.now() + 60_000).reindex({ force: true }); ``` `caller.actions.*` is action-only. If a callback can run from mutation or action context, keep the seam honest and schedule the action: ```ts title="convex/functions/jobs.ts" showLineNumbers {1,4} import { requireSchedulerCtx } from 'kitcn/server'; const caller = createJobsCaller(requireSchedulerCtx(ctx)); await caller.schedule.now.reindex({ force: true }); ``` }> **Note:** In actions, each `createCaller(ctx)` call runs as a separate Convex transaction. Prefer aggregating related reads/writes into a single internal query/mutation when consistency matters. }> **Bundle size:** Each caller eagerly loads every procedure in its module — there is no lazy loading. If a module grows large, split it into smaller files so callers only pull in what they need. createHandler(ctx) [#createmodulehandlerctx] For internal composition where the caller already validated inputs, use `createHandler(ctx)`. It bypasses input validation, middleware, and output validation — calling the raw handler directly. ```ts title="convex/functions/organization.ts" showLineNumbers {3,7-8} import * as z from 'zod'; import { authQuery } from '../lib/crpc'; import { createOrganizationHandler } from './generated/organization.runtime'; export const listOrganizations = authQuery .query(async ({ ctx }) => { const handler = createOrganizationHandler(ctx); const orgs = await handler.listUserOrganizations(); return orgs; }); ``` `createHandler(ctx)` is query/mutation-only (no action context support). Paginated Queries [#paginated-queries] For large datasets, use `.paginated()` for cursor-based pagination. It adds `cursor`, `endCursor`, and `limit` to your input and accepts Convex split metadata in the output. ```ts showLineNumbers {7-8,14} 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 }) => { return ctx.orm.query.session.findMany({ where: input.userId ? { userId: input.userId } : {}, orderBy: { createdAt: 'desc' }, cursor: input.cursor, endCursor: input.endCursor, limit: input.limit, }); }); ``` The handler receives flat `input.cursor`, `input.endCursor`, and `input.limit`. Pass all three to `findMany()`. The output accepts `{ continueCursor, isDone, page, pageStatus?, splitCursor? }` so infinite-query clients can maintain reactive page boundaries. See [Infinite Queries](/docs/react/infinite-queries) for client-side usage with `useInfiniteQuery`. Internal Procedures [#internal-procedures] Use `privateMutation`, `privateQuery`, or `privateAction` for procedures only callable from other Convex functions. These are perfect for scheduled jobs, background processing, and server-to-server calls. ```ts showLineNumbers {1,7} export const processJob = privateMutation .input(z.object({ data: z.string() })) .mutation(async ({ ctx, input }) => { // Only callable via callers or ctx.scheduler }); export const backfillData = privateMutation .input(z.object({ cursor: z.string().nullable() })) .mutation(async ({ ctx, input }) => { // Background job for data migration }); ``` }> **Note:** These builders use `.internal()` under the hood. You can also call `.internal()` on any builder if needed. Next Steps [#next-steps] API Reference [#api-reference] Allowed Call Matrix [#allowed-call-matrix] | Caller context | Root query | Root mutation | Root action | `caller.actions.*` | `caller.schedule.*` | | -------------- | ---------- | ------------- | ----------- | ------------------ | ------------------- | | `QueryCtx` | ✅ | ❌ | ❌ | ❌ | ❌ | | `MutationCtx` | ✅ | ✅ | ❌ | ❌ | ✅ | | `ActionCtx` | ✅ | ✅ | ❌ | ✅ | ✅ | Zod vs Convex Validators [#zod-vs-convex-validators] | Zod | Convex `v` | | ----------------------- | ------------------------ | | `z.string()` | `v.string()` | | `z.number()` | `v.number()` | | `z.boolean()` | `v.boolean()` | | `z.array(z.string())` | `v.array(v.string())` | | `z.object({...})` | `v.object({...})` | | `z.string().optional()` | `v.optional(v.string())` | | `zid('tablename')` | `v.id('tablename')` | # Scheduling (/docs/server/scheduling) import { InfoIcon } from "lucide-react" Overview [#overview] Convex provides two ways to run [functions](/docs/server/procedures) in the future: | Type | Use For | | ------------------- | ----------------------------------- | | Cron jobs | Recurring tasks on a fixed schedule | | Scheduled functions | One-time delayed execution | View scheduled jobs in the [Dashboard](https://dashboard.convex.dev) under the **Schedules** tab. When to Use Scheduling [#when-to-use-scheduling] Quick reference for choosing the right approach: | Scenario | Cron Jobs | Scheduled Functions | | ----------------------- | ---------------- | -------------------------- | | Daily cleanup | ✅ Fixed schedule | ❌ | | Send email after signup | ❌ | ✅ `schedule.now` | | Subscription expiration | ❌ | ✅ `schedule.at(timestamp)` | | Hourly analytics | ✅ Fixed schedule | ❌ | | Reminder notifications | ❌ | ✅ `schedule.at(time)` | | Database maintenance | ✅ Off-peak hours | ❌ | | Order processing delay | ❌ | ✅ `schedule.after(5000)` | }> **Tip:** Use `schedule.now` to trigger [actions](/docs/server/procedures#actions) immediately after a mutation commits — perfect for sending emails, webhooks, or other side effects. Cron Jobs [#cron-jobs] Cron jobs run on a fixed schedule - hourly, daily, or using cron expressions. Setup [#setup] Create `convex/functions/crons.ts` to define recurring jobs: ```ts title="convex/functions/crons.ts" showLineNumbers {4,7-11,14-19} import { cronJobs } from 'convex/server'; import { internal } from './_generated/api'; const crons = cronJobs(); // Run every 2 hours crons.interval( 'cleanup stale data', { hours: 2 }, internal.crons.cleanupStaleData, {} ); // Run at specific times using cron syntax crons.cron( 'daily report', '0 9 * * *', // Every day at 9 AM UTC internal.crons.generateDailyReport, {} ); export default crons; ``` }> **Note:** Always import `internal` from `./_generated/api`, even for functions in the same file. Cron Expressions [#cron-expressions] Common cron patterns: | Pattern | Description | | -------------- | ------------------ | | `* * * * *` | Every minute | | `*/15 * * * *` | Every 15 minutes | | `0 * * * *` | Every hour | | `0 0 * * *` | Daily at midnight | | `0 9 * * *` | Daily at 9 AM | | `0 9 * * 1-5` | Weekdays at 9 AM | | `0 0 1 * *` | First day of month | Format: `minute hour day-of-month month day-of-week` }> **Note:** Cron jobs run in **UTC timezone**. Minimum interval is 1 minute. Handler Implementation [#handler-implementation] Handler functions that cron jobs call: ```ts title="convex/functions/crons.ts" showLineNumbers {5-13,15-25} import * as z from 'zod'; import { privateMutation, privateAction } from '../lib/crpc'; import { createAnalyticsCaller } from './generated/analytics.runtime'; import { createReportsCaller } from './generated/reports.runtime'; export const cleanupStaleData = privateMutation .input(z.object({})) .output(z.object({ deletedCount: z.number() })) .mutation(async ({ ctx }) => { const thirtyDaysAgo = Date.now() - 30 * 24 * 60 * 60 * 1000; // Index session.lastActiveAt. const staleSessions = await ctx.orm.query.session.findMany({ where: { lastActiveAt: { lt: thirtyDaysAgo } }, limit: 1000, }); for (const sessionRow of staleSessions) { await ctx.orm.delete(session).where(eq(session.id, sessionRow.id)); } return { deletedCount: staleSessions.length }; }); export const generateDailyReport = privateAction .input(z.object({})) .action(async ({ ctx }) => { const analyticsCaller = createAnalyticsCaller(ctx); const reportsCaller = createReportsCaller(ctx); const stats = await analyticsCaller.getDailyStats({}); await reportsCaller.create({ type: 'daily', data: stats, }); return null; }); ``` Scheduled Functions [#scheduled-functions] Scheduled functions run once at a specific time or after a delay. }> **Warning:** Auth context is NOT available in scheduled functions. Pass `userId` or other auth data as arguments when scheduling. Key behavioral constraints: scheduling from mutations is atomic (failure cancels the schedule), limits are 1000 functions / 8MB args per function, and results are retained for 7 days. See [Scheduled Function Behavior](#scheduled-function-behavior) in the API Reference. schedule.after [#scheduleafter] Use `schedule.after(delayMs)` to schedule a function to run after a delay (in milliseconds): ```ts title="convex/functions/orders.ts" showLineNumbers {3,12-14} import * as z from 'zod'; import { authMutation } from '../lib/crpc'; import { createOrdersCaller } from './generated/orders.runtime'; export const processOrder = authMutation .input(z.object({ orderId: z.string() })) .mutation(async ({ ctx, input }) => { await ctx.orm .update(orders) .set({ status: 'processing' }) .where(eq(orders.id, input.orderId)); // Run after 5 seconds const caller = createOrdersCaller(ctx); await caller.schedule.after(5000).charge({ orderId: input.orderId, }); return null; }); ``` schedule.now [#schedulenow] Use `schedule.now` to trigger actions immediately after a mutation commits: ```ts title="convex/functions/items.ts" showLineNumbers {1,8-11} import { createItemsCaller } from './generated/items.runtime'; export const createItem = authMutation .input(z.object({ name: z.string() })) .output(z.string()) .mutation(async ({ ctx, input }) => { const [row] = await ctx.orm .insert(items) .values({ name: input.name }) .returning({ id: items.id }); const itemId = row.id; // Action runs immediately after mutation commits const caller = createItemsCaller(ctx); await caller.schedule.now.sendNotification({ itemId }); return itemId; }); ``` This is perfect for sending emails, webhooks, or other side effects that shouldn't block the mutation. schedule.at [#scheduleat] Use `schedule.at(timestamp)` to schedule a function to run at a specific Unix timestamp: ```ts title="convex/functions/reminders.ts" showLineNumbers {1,10-13} import { CRPCError } from 'kitcn/server'; import { createRemindersCaller } from './generated/reminders.runtime'; export const scheduleReminder = authMutation .input(z.object({ message: z.string(), sendAt: z.number(), // Unix timestamp in ms })) .mutation(async ({ ctx, input }) => { if (input.sendAt <= Date.now()) { throw new CRPCError({ code: 'BAD_REQUEST', message: 'Reminder time must be in the future', }); } const caller = createRemindersCaller(ctx); await caller.schedule.at(input.sendAt).send({ message: input.message, }); return null; }); ``` Canceling Scheduled Functions [#canceling-scheduled-functions] Store the job ID to cancel later: ```ts title="convex/functions/subscriptions.ts" showLineNumbers {1,7-10,13-17} import { createSubscriptionsCaller } from './generated/subscriptions.runtime'; export const createSubscription = authMutation .input(z.object({ planId: z.string() })) .output(z.string()) .mutation(async ({ ctx, input }) => { // Schedule expiration in 30 days const caller = createSubscriptionsCaller(ctx); const expirationJobId = await caller.schedule .after(30 * 24 * 60 * 60 * 1000) .expire({ userId: ctx.userId }); // Store job ID for cancellation const [row] = await ctx.orm .insert(subscriptions) .values({ userId: ctx.userId, planId: input.planId, expirationJobId, }) .returning({ id: subscriptions.id }); return row.id; }); ``` Then cancel using `caller.schedule.cancel()`: ```ts title="convex/functions/subscriptions.ts" showLineNumbers {1,12-15} import { createSubscriptionsCaller } from './generated/subscriptions.runtime'; export const cancelSubscription = authMutation .input(z.object({ subscriptionId: z.string() })) .mutation(async ({ ctx, input }) => { const subscription = await ctx.orm.query.subscriptions.findFirst({ where: { id: input.subscriptionId }, }); if (!subscription) { throw new CRPCError({ code: 'NOT_FOUND', message: 'Subscription not found' }); } // Cancel the scheduled expiration const caller = createSubscriptionsCaller(ctx); if (subscription.expirationJobId) { await caller.schedule.cancel(subscription.expirationJobId); } await ctx.orm .delete(subscriptions) .where(eq(subscriptions.id, subscription.id)); return null; }); ``` Checking Status [#checking-status] You can query the `_scheduled_functions` system table to check job status: ```ts title="convex/functions/jobs.ts" showLineNumbers {3-12} import { publicQuery } from '../lib/crpc'; export const getJobStatus = publicQuery .input(z.object({ jobId: z.string() })) .output(z.object({ name: z.string(), scheduledTime: z.number(), completedTime: z.number().optional(), state: z.object({ kind: z.enum(['pending', 'inProgress', 'success', 'failed', 'canceled']), }), }).nullable()) .query(async ({ ctx, input }) => { return await ctx.orm.system.get(input.jobId); }); ``` List all pending jobs: ```ts title="convex/functions/jobs.ts" showLineNumbers {3-12} export const listPendingJobs = publicQuery .input(z.object({})) .output(z.array(z.object({ id: z.string(), name: z.string(), scheduledTime: z.number(), }))) .query(async ({ ctx }) => { const jobs = await ctx.orm.system .query('_scheduled_functions') .filter((q) => q.eq(q.field('state.kind'), 'pending')) .collect(); return jobs.map(({ id, name, scheduledTime }) => ({ id, name, scheduledTime, })); }); ``` Job States [#job-states] | State | Description | | ------------ | ---------------------------------------------------- | | `pending` | Not started yet | | `inProgress` | Currently running (actions only) | | `success` | Completed successfully | | `failed` | Hit an error | | `canceled` | Canceled via dashboard or `caller.schedule.cancel()` | Error Handling [#error-handling] Mutations are automatically retried on internal Convex errors and execute exactly once. Actions are **not** automatically retried (they may have side effects) and execute at most once. See [Error Retry Behavior](#error-retry-behavior) in the API Reference for the full breakdown. }> **Warning:** For critical actions that must succeed, implement manual retry with exponential backoff. See [Error Handling](/docs/server/error-handling) for patterns. API Reference [#api-reference] Scheduled Function Behavior [#scheduled-function-behavior] | Concept | Description | | ------------------------- | ------------------------------------------------------------------------------ | | **Atomicity** | Scheduling from mutations is atomic - if mutation fails, nothing is scheduled | | **Non-atomic in actions** | Scheduled functions from actions run even if the action fails | | **Limits** | Single function can schedule up to 1000 functions with 8MB total argument size | | **Auth not propagated** | Pass user info as arguments if needed | | **Results retention** | Available for 7 days after completion | Error Retry Behavior [#error-retry-behavior] Mutations [#mutations] | Behavior | Description | | ------------------------ | ------------------------------------------------ | | **Automatic retry** | Internal Convex errors are automatically retried | | **Guaranteed execution** | Once scheduled, mutations execute exactly once | | **Permanent failure** | Only fails on developer errors | Actions [#actions] | Behavior | Description | | ---------------------- | --------------------------------------------- | | **No automatic retry** | Actions may have side effects, so not retried | | **At most once** | Actions execute at most once | | **Manual retry** | Implement retry logic if needed | Next Steps [#next-steps] # Server Side Calls (/docs/server/server-side-calls) import { InfoIcon } from "lucide-react" Environment Setup [#environment-setup] The caller requires the Convex **site URL** (for HTTP actions), which is different from the real-time URL. }> **Note:** The site URL uses port 3211 locally and `.site` domain in production. ```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 ``` ```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 ``` Create Caller [#create-caller] Without Auth [#without-auth] ```ts title="src/lib/convex/server.ts" showLineNumbers {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!, }); ``` Better Auth (Next.js) [#better-auth-nextjs] For Better Auth with Next.js, see [Next.js Setup](/docs/nextjs) which uses `convexBetterAuth` from `kitcn/auth/nextjs`. Custom Auth [#custom-auth] Use `createCallerFactory` with a custom `getToken` function. ```ts title="src/lib/convex/server.ts" showLineNumbers {7-11} import { api } from '@convex/api'; import { createCallerFactory } from 'kitcn/server'; export const { createContext, createCaller } = createCallerFactory({ api, convexSiteUrl: process.env.NEXT_PUBLIC_CONVEX_SITE_URL!, auth: { getToken: async (siteUrl, headers) => { // Extract token from headers (implement your auth logic) const token = headers.get('authorization')?.replace('Bearer ', ''); return { token }; }, }, }); ``` }> **Note:** The generated `api` object already includes cRPC metadata. No separate `meta` parameter is needed. If you customize `transformer`, use the same transformer in: * `initCRPC.create({ transformer })` * `createCRPCContext({ transformer })` * `createCallerFactory({ transformer })` * `getServerQueryClientOptions({ transformer })` `Date` support stays always on; custom transformer is additive only. createCallerFactory vs generated runtime factories [#createcallerfactory-vs-generated-runtime-factories] There are two caller APIs, each for a different context: | API | Location | When to use | Transport | | ---------------------------- | ------------------------------------------------ | ------------------------------------------------- | ------------------------------------------------------- | | `createCallerFactory` | `kitcn/server` | Server-side HTTP calls (Next.js API routes, RSC) | Convex fetch APIs | | `createHandler(ctx)` | `convex/functions/generated/.runtime.ts` | Query/mutation composition inside Convex handlers | Direct handler | | `createCaller(ctx)` | `convex/functions/generated/.runtime.ts` | Action/HTTP composition inside Convex handlers | `runQuery`, `runMutation`, `runAction`, and `scheduler` | Use `createHandler(ctx)` as the default inside queries and mutations. Use `createCaller(ctx)` in actions and HTTP routes, where handler factories are unavailable. ```ts title="convex/functions/teams.ts" showLineNumbers {3,7-9} import * as z from 'zod'; import { privateQuery } from '../lib/crpc'; import { createTeamsHandler } from './generated/teams.runtime'; export const getTeamAndOwner = privateQuery .input(z.object({ teamId: z.string() })) .query(async ({ ctx, input }) => { const handler = createTeamsHandler(ctx); const team = await handler.getTeam({ teamId: input.teamId }); const owner = await handler.getOwner({ id: team.ownerId }); return { team, owner }; }); ``` }> **Note:** In actions, each `createCaller(ctx)` call runs as a separate Convex transaction. Prefer aggregating related reads/writes into a single internal query/mutation when consistency matters. For the full call matrix and type definitions, see [API Reference](#api-reference) below. For more details on composition, see [In-Process Procedure Composition](/docs/server/procedures#in-process-procedure-composition). Usage [#usage] Create context from headers, then use `ctx.caller`. ```ts title="src/app/api/users/route.ts" showLineNumbers {4,7-8,11-14,17-19} import { createContext } from '@/lib/convex/server'; // Create context from request headers const ctx = await createContext({ headers: request.headers }); // Query const users = await ctx.caller.user.list({}); // Query with input const user = await ctx.caller.user.getById({ id: userId }); // Mutation const newId = await ctx.caller.user.create({ name: 'John', email: 'john@example.com', }); // Check auth state if (!ctx.isAuthenticated) { redirect('/login'); } ``` Hono Middleware [#hono-middleware] ```ts title="src/middleware.ts" showLineNumbers {1-2} const ctx = await createContext({ headers: c.req.raw.headers }); const user = await ctx.caller.user.getSessionUser({}); ``` API Routes [#api-routes] ```ts title="src/app/api/data/route.ts" showLineNumbers {1-2} const ctx = await createContext({ headers: request.headers }); const data = await ctx.caller.user.list({}); ``` API Reference [#api-reference] Call Matrix [#call-matrix] What each caller context can invoke via `createCaller(ctx)`: | Caller context | Root query | Root mutation | Root action | `caller.actions.*` | `caller.schedule.*` | | -------------- | ---------- | ------------- | ----------- | ------------------ | ------------------- | | `QueryCtx` | ✅ | ❌ | ❌ | ❌ | ❌ | | `MutationCtx` | ✅ | ✅ | ❌ | ❌ | ✅ | | `ActionCtx` | ✅ | ✅ | ❌ | ✅ | ✅ | If `ctx` is already typed as `ActionCtx`, call the generated caller directly. Use `caller.actions.*` only from a real action context. If the flow can run from a mutation or generic scheduler-capable callback, do not force `requireActionCtx(ctx)` just to reach an action. Schedule it instead: ```ts showLineNumbers {1,4} import { requireSchedulerCtx } from 'kitcn/server'; const caller = createJobsCaller(requireSchedulerCtx(ctx)); await caller.schedule.now.reindex({ force: true }); ``` Only use `requireActionCtx(ctx)` when the callback truly runs in `ActionCtx` and you need `caller.actions.*`. createCallerFactory [#createcallerfactory] Creates a caller factory with token management and retry logic. ```ts title="types.ts" showLineNumbers type CreateCallerFactoryOptions = { api: TApi; convexSiteUrl: string; auth?: { getToken: ( siteUrl: string, headers: Headers, opts?: unknown ) => Promise<{ token?: string; isFresh?: boolean }>; isUnauthorized?: (error: unknown) => boolean; }; // Additive only. Built-in Date transformer remains enabled. transformer?: DataTransformer | { input: DataTransformer; output: DataTransformer }; }; ``` Returns `{ createContext, createCaller }`: ```ts title="types.ts" showLineNumbers type ConvexContext = { token: string | undefined; isAuthenticated: boolean; caller: ServerCaller; }; // createContext: (opts: { headers: Headers }) => Promise> // createCaller: (ctxFn: () => Promise>) => LazyCaller ``` Next Steps [#next-steps] # Setup (/docs/server/setup) import { InfoIcon } from "lucide-react" Generate Runtime [#generate-runtime] Start `kitcn dev` — this runs Convex and watches for changes, regenerating runtime files automatically: ```bash npx kitcn dev ``` Initialize cRPC [#initialize-crpc] `/generated/server.ts` is the canonical server contract. It exports `initCRPC` with ORM pre-wired when your default schema chains `.relations(...)`. `/generated/auth.ts` is the auth contract. ```ts title="convex/lib/crpc.ts" showLineNumbers import { initCRPC } from '../functions/generated/server'; const c = initCRPC.create(); ``` Payload Transformer (Optional) [#payload-transformer-optional] cRPC serializes inputs/outputs through a transformer at the procedure boundary. * `Date` support is always enabled (cannot be disabled) * The reserved wire tag is `$date` * Pass a custom transformer only when you need extra wire-safe types * Custom transformer is additive to the built-in Date transformer ```ts title="convex/lib/crpc.ts" showLineNumbers import { createTaggedTransformer } from 'kitcn/crpc'; import { initCRPC } from '../functions/generated/server'; const transformer = createTaggedTransformer([ // Add custom codecs here (Date is already built-in) ]); const c = initCRPC.create({ transformer }); ``` `create({ transformer })` accepts either: * A single `DataTransformer` * A split `{ input, output }` transformer object Export Procedure Builders [#export-procedure-builders] Next, we'll export the procedure builders that you'll use throughout your codebase. These are the building blocks for all your queries, mutations, and actions. ```ts title="convex/lib/crpc.ts" showLineNumbers {1-6,8-10} // Public procedures - accessible from client export const publicQuery = c.query; export const publicMutation = c.mutation; export const publicAction = c.action; // Internal procedures - only callable from other backend functions export const privateQuery = c.query.internal(); export const privateMutation = c.mutation.internal(); export const privateAction = c.action.internal(); // HTTP route builders (for REST APIs) export const publicRoute = c.httpAction; export const router = c.router; ``` }> `.internal()` prevents client access — use for scheduled functions, webhooks, and server-to-server calls. Define Your Schema [#define-your-schema] Before creating procedures, you'll need a database schema. These docs assume the ORM (see [/docs/orm](/docs/orm)): ```ts title="convex/functions/schema.ts" showLineNumbers {1-7,9-29,31-42} import { convexTable, defineSchema, id, index, integer, text, } from 'kitcn/orm'; export const user = convexTable( 'user', { name: text().notNull(), email: text().notNull() }, (t) => [index('email').on(t.email)] ); export const session = convexTable( 'session', { token: text().notNull(), userId: id('user').notNull(), expiresAt: integer().notNull(), }, (t) => [index('token').on(t.token), index('userId').on(t.userId)] ); export const tables = { user, session }; export default defineSchema(tables, { strict: false, }).relations((r) => ({ user: { sessions: r.many.session({ from: r.user.id, to: r.session.userId }), }, session: { user: r.one.user({ from: r.session.userId, to: r.user.id }), }, })); ``` }> Without codegen, use `initCRPC` from `kitcn/server` with `.dataModel()` and optional `.context()`. Your First Procedure [#your-first-procedure] ```ts title="convex/functions/user.ts" showLineNumbers import { query } from 'convex/server'; import { v } from 'convex/values'; export const list = query({ args: { limit: v.number() }, handler: async (ctx, args) => { return ctx.db .query('user') .order('desc') .take(args.limit); }, }); ``` ```ts title="convex/functions/user.ts" showLineNumbers {4-7} import * as z from 'zod'; import { publicQuery } from '../lib/crpc'; export const list = publicQuery .input(z.object({ limit: z.number() })) .query(async ({ ctx, input }) => { return ctx.orm.query.user.findMany({ limit: input.limit }); }); ``` Procedure Types [#procedure-types] | Type | Builder | Use For | | ----------- | -------------- | --------------------------------------------- | | Query | `c.query` | Read-only operations, real-time subscriptions | | Mutation | `c.mutation` | Write operations, transactional updates | | Action | `c.action` | Side effects, external API calls | | HTTP Action | `c.httpAction` | REST endpoints, webhooks | Adding Middleware [#adding-middleware] Define a middleware that checks auth and adds the user to context: ```ts title="convex/lib/crpc.ts" showLineNumbers {1-12} const authMiddleware = c.middleware(async ({ ctx, next }) => { const identity = await ctx.auth.getUserIdentity(); if (!identity) { throw new CRPCError({ code: 'UNAUTHORIZED' }); } const user = await ctx.orm.query.user.findFirst({ where: { email: identity.email! }, }); if (!user) throw new CRPCError({ code: 'UNAUTHORIZED' }); return next({ ctx: { ...ctx, user } }); }); ``` Create authenticated procedure variants: ```ts title="convex/lib/crpc.ts" showLineNumbers {1-2} export const authQuery = c.query.use(authMiddleware); export const authMutation = c.mutation.use(authMiddleware); ``` `ctx.user` is now available in handlers: ```ts title="convex/functions/user.ts" showLineNumbers {4} export const me = authQuery .output(userSchema) .query(async ({ ctx }) => { return ctx.user; // Typed! We know user exists }); ``` Adding Metadata [#adding-metadata] ```ts title="convex/lib/crpc.ts" showLineNumbers {1-5,9} const c = initCRPC .meta<{ auth?: 'optional' | 'required'; role?: 'admin'; ratelimit?: string; }>() .create(); ``` Middleware reads metadata: ```ts title="convex/lib/crpc.ts" showLineNumbers {1-6} const roleMiddleware = c.middleware(({ ctx, meta, next }) => { if (meta.role === 'admin' && !ctx.user?.isAdmin) { throw new CRPCError({ code: 'FORBIDDEN' }); } return next({ ctx }); }); export const adminQuery = c.query .meta({ role: 'admin' }) .use(authMiddleware) .use(roleMiddleware); ``` Customizing Context [#customizing-context] With generated `initCRPC`, ORM context is already wired. Chain `.use()` for additional context (e.g. auth middleware). Common Procedure Variants [#common-procedure-variants] | Variant | Description | Middleware | | ------------------- | ---------------------- | ----------------- | | `publicQuery` | No auth required | None | | `optionalAuthQuery` | `ctx.user` may be null | Optional auth | | `authQuery` | `ctx.user` guaranteed | Auth required | | `authMutation` | Auth + rate limiting | Auth + rate limit | | `adminQuery` | Auth + admin role | Auth + role check | For production bootstrap, start with [CLI Registry](/docs/cli/registry). Use this page when you want to wire the backend by hand. File Organization [#file-organization] Each file in `convex/functions/` becomes a namespace: ``` convex/ ├── functions/ │ ├── schema.ts # Database schema │ ├── user.ts # → crpc.user.list, crpc.user.create │ ├── session.ts # → crpc.session.getByToken │ ├── account.ts # → crpc.account.list, crpc.account.delete │ └── generated/ │ ├── server.ts # Generated server contract (ORM + initCRPC) │ └── auth.ts # Generated auth contract ├── lib/ │ └── crpc.ts # Setup + procedure variants └── shared/ # Client-importable └── api.ts # Generated procedure metadata ``` Client proxy mirrors this: ```ts title="src/components/example.tsx" showLineNumbers // File: convex/functions/user.ts, export: list crpc.user.list.queryOptions({ limit: 10 }) // File: convex/functions/session.ts, export: getByToken crpc.session.getByToken.queryOptions({ token: 'abc' }) ``` Next Steps [#next-steps] # Plugins (/docs/plugins) Plugins are first-class in kitcn. Install the runtime package, scaffold local files with `kitcn add`, then keep customizing those local files. Runtime vs schema [#runtime-vs-schema] First-party plugins are split on purpose: * Runtime package: middleware, helpers, and types * Local schema extension: `convex/lib/plugins//schema.ts` * Local runtime files: `convex/functions/plugins/.ts` and `convex/lib/plugins//...` Schema is app-owned and uses `defineSchemaExtension(...)`: ```ts showLineNumbers import { defineSchemaExtension } from 'kitcn/orm'; export function myExtension() { return defineSchemaExtension('my-plugin', {}).relations(() => ({})); } ``` Discovery model [#discovery-model] `defineSchema(...).extend(...)` is the source of truth for installed schema capabilities: ```ts showLineNumbers {2,5} import { defineSchema } from 'kitcn/orm'; import { resendExtension } from '../lib/plugins/resend/schema'; export default defineSchema(tables).extend(resendExtension()); ``` Extension composition rules: * Extension `relations(...)` merge before app `relations`. * Extension `triggers` merge before app `triggers`. * Duplicate relation fields and trigger hooks throw. Ownership model (shadcn-style) [#ownership-model-shadcn-style] * Packages own stable runtime helpers. * The CLI owns scaffold templates. * Your app owns scaffolded schema and runtime files. * `kitcn add ` creates or refreshes local plugin files. * If `paths.env` is missing, `kitcn add ` also bootstraps `convex/lib/get-env.ts` and writes `paths.env` into `kitcn.json`. * `kitcn diff ` previews scaffold drift. * Installed plugin state is tracked in `/plugins.lock.json`. Codegen behavior: * Installed extension keys drive generated plugin typing. * Plugin runtime modules stay scaffold-owned. * Stale `generated/plugins/**` artifacts are removed automatically. Available plugins [#available-plugins] | Plugin | What it gives you | | -------------------------------------- | ----------------------------------------------------------------------------------- | | [`ratelimit`](/docs/plugins/ratelimit) | Convex-native rate limiting with middleware + React hook support | | [`resend`](/docs/plugins/resend) | Durable queued email delivery, batching, webhook status ingestion, and cleanup APIs | Built-in internal extensions [#built-in-internal-extensions] These are always active and do not require explicit registration: | Extension | Purpose | | ----------- | ----------------------------------- | | `aggregate` | Aggregate storage + runtime support | | `migration` | Migration state + runtime support | Next steps [#next-steps] # Rate Limiting (/docs/plugins/ratelimit) import { InfoIcon } from "lucide-react" Why this package [#why-this-package] `kitcn/ratelimit` is designed as a hard cutover from component-driven APIs. | What you get | Why it matters | | ------------------- | --------------------------------------------------------------------------------------------------------- | | Upstash-style API | Easier migration if your team already knows Upstash (`limit`, `check`, `getRemaining`, `resetUsedTokens`) | | Convex-first tables | No component registration and no component migration path to manage | | Read dedupe helpers | Common repeated reads may reuse cached results and reduce duplicate DB fetches | | React hook support | `hookAPI()` + `useRatelimit()` gives accurate client countdown and button states | | Fail-closed default | Safer behavior under pressure (`failureMode: "closed"`) | Install [#install] Scaffold the starter (required) [#scaffold-the-starter-required] Rate limiting is opt-in, so scaffold the full starter once: ```bash showLineNumbers npx kitcn add ratelimit ``` That creates: * `convex/lib/plugins/ratelimit/schema.ts` * `convex/lib/plugins/ratelimit/plugin.ts` * `convex/functions/plugins/ratelimit.ts` and registers `ratelimitExtension()` in `convex/functions/schema.ts`. ```ts title="convex/functions/schema.ts" showLineNumbers {1-2,6} import { defineSchema } from 'kitcn/orm'; import { ratelimitExtension } from '../lib/plugins/ratelimit/schema'; export const tables = { // your tables... }; export default defineSchema(tables).extend(ratelimitExtension()); ``` Create a local ratelimit plugin [#create-a-local-ratelimit-plugin] Scaffold a local plugin with a default bucket for every mutation and an `interactive` example for stricter procedures. Rename or remove the example bucket to match your application. ```ts title="convex/lib/plugins/ratelimit/plugin.ts" showLineNumbers import { type LimitRequest, MINUTE, Ratelimit, RatelimitPlugin, SECOND, } from 'kitcn/ratelimit'; import type { MutationCtx } from '../../../functions/generated/server'; const fixed = (rate: number) => Ratelimit.fixedWindow(rate, MINUTE); export const ratelimitBuckets = { default: { public: fixed(30), free: fixed(60), premium: fixed(200), }, interactive: { public: Ratelimit.fixedWindow(3, 30 * SECOND), free: Ratelimit.fixedWindow(3, 30 * SECOND), premium: Ratelimit.fixedWindow(3, 30 * SECOND), }, } as const; type RatelimitTier = keyof (typeof ratelimitBuckets)['default']; export type RatelimitBucket = keyof typeof ratelimitBuckets; type RatelimitUser = { id: string; isAdmin?: boolean; plan?: 'premium' | 'team' | null; }; type RatelimitCtx = MutationCtx & { user?: RatelimitUser | null; }; type RatelimitMeta = { ratelimit?: RatelimitBucket; }; export function getUserTier(user: RatelimitUser | null): RatelimitTier { if (!user) return 'public'; if (user.isAdmin || user.plan) return 'premium'; return 'free'; } async function getRequestSignals(ctx: RatelimitCtx) { const { ip, userAgent } = await ctx.meta.getRequestMetadata(); return { ...(ip ? { ip } : {}), ...(userAgent ? { userAgent } : {}), }; } function getRequestIdentifier( user: RatelimitUser | null, signals: LimitRequest | undefined ) { if (user) return user.id; return signals?.ip ? `ip:${signals.ip}` : 'ip:unknown'; } export const ratelimit = RatelimitPlugin.configure({ buckets: ratelimitBuckets, getBucket: ({ meta }: { meta: RatelimitMeta }) => meta.ratelimit ?? 'default', getUser: ({ ctx }: { ctx: RatelimitCtx }) => ctx.user ?? null, getTier: getUserTier, getSignals: ({ ctx }: { ctx: RatelimitCtx }) => getRequestSignals(ctx), getIdentifier: ({ user, signals, }: { user: RatelimitUser | null; signals: LimitRequest | undefined; }) => getRequestIdentifier(user, signals), prefix: ({ bucket, tier }) => `ratelimit:${bucket}:${tier}`, failureMode: 'closed', enableProtection: true, denyListThreshold: 30, }); ``` Convex exposes request metadata in mutation and action functions: request ID, client IP, and client user-agent. For `RatelimitPlugin`, read it from mutation middleware. Use it for anonymous flows too — session-based helpers return `{}` when no session exists, which is exactly when IP-aware protection matters most. Pick an identifier that partitions [#pick-an-identifier-that-partitions] The identifier is the rate-limit partition key. Everything that resolves to the same string shares one budget and one `ratelimitState` document, so a constant like `'anonymous'` makes `fixedWindow(30, MINUTE)` a deployment-wide 30 requests per minute for all unauthenticated traffic — one crawler denies every visitor. `getSignals` runs once per request, before `getIdentifier`, and its result is passed straight through, so keying on the request IP costs no extra `getRequestMetadata()` syscall. Two consequences to plan for: * **Shared IPs share a budget.** NAT, corporate proxies, and mobile CGNAT put many people behind one address. Raise the public-tier budget accordingly, or add a captcha-gated tier. * **Calls without request metadata collapse to one key.** They land on `ip:unknown`. Keep them off `publicMutation`, or give them their own bucket. }> **Retention:** one `ratelimitState` row exists per identifier per `bucket:tier`. Per-IP keys make that row count grow with distinct visitors. The starter includes an indexed, batched private mutation for manual cleanup. Clean up stored state on demand [#clean-up-stored-state-on-demand] Choose `olderThanMs` longer than every configured window, refill horizon, or reservation period. Run the private mutation manually and repeat only while it returns `hasMore: true`: ```bash bunx convex run plugins/ratelimit:cleanup '{"olderThanMs":86400000}' ``` Add `--prod` when targeting production. Each call deletes at most 500 rows by default; pass `limit` from 1 through 1000 to change the batch size. Plugin options [#plugin-options] | Option | Type | Description | | --------------- | ------------------------------------------------------------------ | ----------------------------------------------------------------- | | `buckets` | `Record>` | **Required.** Named buckets, each mapping a tier to an algorithm. | | `getBucket` | `({ ctx, meta }) => bucket` | **Required.** Picks the bucket, usually from `meta.ratelimit`. | | `getUser` | `({ ctx, meta }) => user` | **Required.** Resolves the caller. | | `getTier` | `(user) => tier` | **Required.** Picks the tier within the bucket. | | `getSignals` | `({ ctx, meta, user, bucket, tier }) => LimitRequest \| undefined` | **Required.** Resolved once per request, before `getIdentifier`. | | `getIdentifier` | `({ ctx, meta, user, bucket, tier, signals }) => string` | **Required.** The partition key. | | `prefix` | `string \| (args) => string` | Namespaces stored state. Defaults to `ratelimit::`. | | `message` | `string \| (args) => string` | Message on the thrown `TOO_MANY_REQUESTS` error. | Everything below is forwarded to the limiter and behaves exactly as in [Constructor options](#constructor-options): `failureMode`, `timeout`, `enableProtection`, `denyListThreshold`, `denyList`, `dynamicLimits`, `ephemeralCache`. }> **Note:** `ctx.meta.getRequestMetadata()` requires Convex 1.38.0 or newer. Wire it into middleware [#wire-it-into-middleware] Apply the plugin once in your mutation builders. The default bucket covers normal writes. `meta.ratelimit` is an optional named-bucket override. ```ts title="convex/lib/crpc.ts" showLineNumbers {1,4-5,8-10} import { ratelimit, type RatelimitBucket } from './plugins/ratelimit/plugin'; const c = initCRPC .meta<{ ratelimit?: RatelimitBucket }>() .create(); export const publicMutation = c.mutation.use(ratelimit.middleware()); ``` Normal writes use the default bucket: ```ts showLineNumbers export const createTodo = authMutation .input(z.object({ title: z.string().min(1) })) .mutation(async ({ ctx, input }) => { // business logic }); ``` Add a named bucket only for exceptions: ```ts showLineNumbers export const stressTest = publicMutation .meta({ ratelimit: 'interactive' }) .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { // stricter bucket }); ``` Choose your algorithm [#choose-your-algorithm] Start simple and pick based on workload shape. Fixed window [#fixed-window] Best when hard windows are acceptable. Tokens reset at the start of each window. ```ts showLineNumbers const limiter = new Ratelimit({ db: ctx.db, prefix: 'post:create', limiter: Ratelimit.fixedWindow(10, '1 m'), }); ``` Sliding window [#sliding-window] Best when you want smoother request shaping without hard resets. Weighs the previous window proportionally so you don't get bursts at window boundaries. ```ts showLineNumbers const limiter = new Ratelimit({ db: ctx.db, prefix: 'search', limiter: Ratelimit.slidingWindow(50, '1 m'), }); ``` Token bucket [#token-bucket] Best for burst-friendly throughput with long-term control. Tokens refill at a steady rate up to `maxTokens`. Use `maxReserved` to allow requests to "borrow" from future tokens when the bucket is empty. ```ts showLineNumbers {4} const limiter = new Ratelimit({ db: ctx.db, prefix: 'llm:tokens', limiter: Ratelimit.tokenBucket(1000, '1 m', 1000, { maxReserved: 3000 }), }); ``` Algorithm options [#algorithm-options] All three algorithm builders accept an optional `options` object as the last argument. | Option | Type | Default | Description | | ------------- | -------- | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `shards` | `number` | `1` | Number of shards for write distribution. The configured budget is dealt across the shards and adds back up to the total you wrote, so the enforced limit is unchanged. Higher values reduce contention at the cost of less precise counts (see [Sharding](#sharding)). | | `maxReserved` | `number` | `undefined` | Finite, non-negative maximum a request can "borrow" from future capacity. Omit it for uncapped reservation headroom. Dealt across shards as whole-token headroom. Only applies to `fixedWindow` and `tokenBucket`. Not supported by `slidingWindow`. | | `capacity` | `number` | `limit` | Maximum stored tokens. Only applies to `fixedWindow`. Useful when you want a higher burst capacity than the per-window refill. Dealt across shards like `limit`, so it must also be at least `shards`. | | `start` | `number` | `0` | Epoch offset (ms) for window alignment. Only applies to `fixedWindow`. Aligns windows to a custom origin instead of epoch zero. | Duration formats [#duration-formats] Every `window` or `interval` parameter accepts a `Duration` — either a raw millisecond number or a human-readable string. **String format:** `" "` or `""`. Both `'1 m'` and `'1m'` work. | Unit | Meaning | Example | | ---- | ------------ | ---------- | | `ms` | milliseconds | `'500 ms'` | | `s` | seconds | `'30 s'` | | `m` | minutes | `'1 m'` | | `h` | hours | `'1 h'` | | `d` | days | `'1 d'` | You can also use the pre-defined constants from `kitcn/ratelimit`: ```ts showLineNumbers import { SECOND, MINUTE, HOUR, DAY, WEEK } from 'kitcn/ratelimit'; Ratelimit.fixedWindow(100, MINUTE); // 60_000 ms Ratelimit.slidingWindow(50, 30 * SECOND); // 30_000 ms Ratelimit.tokenBucket(10, HOUR, 100); // 3_600_000 ms ``` Add a client-side limiter UX [#add-a-client-side-limiter-ux] Server enforcement is mandatory. Client checks are for better UX — disabled buttons, countdowns, and retry hints. Expose the hook API [#expose-the-hook-api] First, export the hook API from a Convex file. The `hookAPI()` method returns a `getRatelimit` query and a `getServerTime` mutation that the React hook consumes. ```ts title="convex/functions/ratelimit.ts" showLineNumbers {1,3-10} import { Ratelimit } from 'kitcn/ratelimit'; const limiter = new Ratelimit({ limiter: Ratelimit.fixedWindow(3, '30 s'), }); export const { getRatelimit, getServerTime } = limiter.hookAPI({ identifier: async (_ctx, fromClient) => fromClient ?? 'anonymous', sampleShards: 1, }); ``` The `identifier` option can be a static string, or an async callback that receives `(ctx, fromClient)`. Use the callback to resolve the identifier server-side (e.g. from auth) while still accepting a client-provided fallback. `sampleShards` controls how many shards to read when estimating the remaining count. Set it to `1` for low-cost reads, or increase it for more accurate estimates on high-shard configs. Use the React hook [#use-the-react-hook] Then wire it up in your component with `useRatelimit`: ```tsx title="src/components/send-button.tsx" showLineNumbers {1,3-10,12-13} import { useRatelimit } from 'kitcn/ratelimit/react'; const ratelimitRef = 'ratelimitDemo:getInteractiveRatelimit' as const; const serverTimeRef = 'ratelimitDemo:getInteractiveServerTime' as const; const { status, check } = useRatelimit(ratelimitRef, { identifier: sessionId, count: 1, getServerTimeMutation: serverTimeRef, }); const blocked = status?.ok === false; const retryAt = status?.retryAt; ``` `useRatelimit` accepts either: * a Convex function path string (`'module:functionName'`) — this is what the `/ratelimit` demo uses. * a generated `FunctionReference` from `api`. The hook returns: | Field | Type | Description | | -------- | ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `status` | `HookStatus \| undefined` | `undefined` while loading. `{ ok: true }` when allowed, `{ ok: false, retryAt: number }` when blocked. Auto-updates when a finite `retryAt` passes. Permanently oversized checks use `Infinity` and schedule no timer. | | `check` | `(ts?, count?) => HookCheckValue \| undefined` | Manual projection function. Call it with a timestamp and count to get a precise snapshot for custom gauges or progress bars. | The `HookCheckValue` returned by `check()` has this shape: | Field | Type | Description | | --------- | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | | `value` | `number` | Projected remaining tokens (negative means over-limit) | | `ts` | `number` | Timestamp of the projection (client time) | | `config` | `ResolvedAlgorithm` | The algorithm config for further calculations | | `shard` | `number` | Which shard was sampled | | `ok` | `boolean` | `true` when the projected request can be served by a shard. Permanent oversized denials can have a non-negative aggregate `value`. | | `retryAt` | `number \| undefined` | Client timestamp when tokens become available. `Infinity` means no shard can serve the requested count. | If you need precise projected values (for custom gauges), call `check(ts, count)`. Protection and deny lists [#protection-and-deny-lists] When `enableProtection` is on, the limiter tracks repeated failures per identifier, IP, user-agent, and country. Once a value reaches `denyListThreshold` **within a rolling 10 minute window**, its block is cached for up to 24 hours without checking the database. Failures paced wider than that window decay instead of accumulating, so a shared NAT or carrier IP is not blocked by unrelated users' failures spread over days. Protection state is a bounded in-memory LRU. It evicts failure histories before blocks and refreshes blocks that keep sending requests. Under more than 4,096 simultaneous blocked values per prefix, the coldest block can be evicted; that value falls through to the normal database-backed rate limiter. A successful request clears the counter for the *identifier* only. IP and user-agent counters survive, because they are attacker-supplied: clearing them on success would let a caller loop `denyListThreshold - 1` failures plus one success forever, and would let anyone reset another client's counter by forging their user-agent. You can also provide static deny lists to block known bad actors immediately. ```ts showLineNumbers {6-14} const limiter = new Ratelimit({ db: ctx.db, prefix: 'api', limiter: Ratelimit.fixedWindow(100, '1 m'), failureMode: 'closed', enableProtection: true, denyListThreshold: 30, denyList: { identifiers: ['known-bad-user-id'], ips: ['203.0.113.0'], userAgents: ['BadBot/1.0'], countries: ['XX'], }, }); ``` To trigger deny-list matching on request metadata, pass `ip`, `userAgent`, or `country` in the `limit()` call. In mutation code, prefer Convex request metadata: ```ts showLineNumbers {1-9} const { ip, userAgent } = await ctx.meta.getRequestMetadata(); const result = await limiter.limit(userId, { ip: ip ?? undefined, userAgent: userAgent ?? undefined, }); ``` For HTTP actions, read the `Request` headers directly: ```ts showLineNumbers {2-5} const result = await limiter.limit(userId, { ip: request.headers.get('x-forwarded-for') ?? undefined, userAgent: request.headers.get('user-agent') ?? undefined, country: request.headers.get('x-country') ?? undefined, }); ``` }> **Important:** Deny-list state is in-memory and non-durable. It can survive across warm runtime requests, but is lost on cold starts/deploys. For persistent blocking, use an external deny list or database-backed blocklist. Tracked values are capped at 4096 per prefix with least-recently-hit eviction, and values longer than 128 characters are stored truncated, so forged `User-Agent` headers cannot grow the map without bound. Counter increments happen in module memory and are not rolled back when Convex retries a mutation after a write conflict, so a value can count slightly more failures than it was actually served. Dynamic limits [#dynamic-limits] Dynamic limits let you change rate limits at runtime — useful for feature flags, admin overrides, or gradual rollouts. Enable them with `dynamicLimits: true` in the constructor. ```ts showLineNumbers {5} const limiter = new Ratelimit({ db: ctx.db, prefix: 'api:search', limiter: Ratelimit.fixedWindow(100, '1 m'), dynamicLimits: true, }); ``` Then use `setDynamicLimit` to override the configured limit at runtime: ```ts showLineNumbers // Double the limit during a sale await limiter.setDynamicLimit({ limit: 200 }); // Read the current override const { dynamicLimit } = await limiter.getDynamicLimit(); // dynamicLimit === 200 // Remove the override (reverts to configured limit) await limiter.setDynamicLimit({ limit: false }); ``` The dynamic limit overrides the `limit` field of the algorithm. For token bucket, it overrides `refillRate` (and `maxTokens` if they were originally equal). Overrides must be positive, finite budgets that every configured shard can serve. Setting or clearing an override advances the limiter's cache generation, invalidates snapshots and ephemeral block decisions, and prevents older in-flight operations from restoring them. Limits and mitigations you should know [#limits-and-mitigations-you-should-know] }> **Important:** This is application-layer limiting. It protects business logic and expensive downstream work, but it is not a network firewall or DDoS shield. Recommended production posture: * Enforce auth early and reject fast. * Protect anonymous flows with captcha + validated session IDs. * Put network-layer controls (Cloudflare or equivalent) in front when IP-based mitigation is required. * Alert on request spikes and fail safely (`failureMode: "closed"` by default). API Reference [#api-reference] Constructor options [#constructor-options] Create a `Ratelimit` instance with a config object: ```ts showLineNumbers const limiter = new Ratelimit(config: RatelimitConfig); ``` | Option | Type | Default | Description | | ------------------- | ------------------------------ | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `db` | `ctx.db` | — | Convex database context. Required for `limit`, `check`, `getRemaining`, `getValue`, `resetUsedTokens`, `setDynamicLimit`, `getDynamicLimit`. Not needed for `hookAPI()` (it receives `db` from the query/mutation context). | | `limiter` | `ResolvedAlgorithm` | — | **Required.** Algorithm created by `Ratelimit.fixedWindow()`, `Ratelimit.slidingWindow()`, or `Ratelimit.tokenBucket()`. | | `prefix` | `string` | `'kitcn/ratelimit'` | Namespaces stored state in the database. Use unique prefixes for different rate limit scopes. | | `dynamicLimits` | `boolean` | `false` | Enables `setDynamicLimit()` / `getDynamicLimit()`. | | `failureMode` | `'closed' \| 'open'` | `'closed'` | Behavior on timeout. `'closed'` rejects, `'open'` allows. | | `timeout` | `number` | `5000` | Milliseconds before triggering `failureMode` behavior. | | `enableProtection` | `boolean` | `false` | Enables deny-list tracking on repeated failures. | | `denyListThreshold` | `number` | `30` | Failures inside a rolling 10-minute window before a value is cached as blocked for up to 24 hours. Requires `enableProtection: true`. | | `denyList` | `ProtectionLists` | `undefined` | Static deny lists. See [Protection and deny lists](#protection-and-deny-lists). | | `ephemeralCache` | `Map \| false` | `new Map()` | In-memory block cache. Shared across requests in the same Convex invocation. Pass `false` to disable. | Algorithm builders [#algorithm-builders] All builders are available as static methods on `Ratelimit`. Ratelimit.fixedWindow(limit, window, options?) [#ratelimitfixedwindowlimit-window-options] ```ts fixedWindow(limit: number, window: Duration, options?: AlgorithmOptions): FixedWindowAlgorithm ``` | Parameter | Type | Description | | --------------------- | ---------- | --------------------------------------------------------------------------- | | `limit` | `number` | Tokens replenished per window | | `window` | `Duration` | Window length (`number` in ms, or string like `'1 m'`) | | `options.shards` | `number` | Write distribution shards (default `1`). The budget is dealt across shards. | | `options.maxReserved` | `number` | Max tokens that can be borrowed from future windows | | `options.capacity` | `number` | Max stored tokens (default = `limit`) | | `options.start` | `number` | Epoch offset for window alignment | Ratelimit.slidingWindow(limit, window, options?) [#ratelimitslidingwindowlimit-window-options] ```ts slidingWindow(limit: number, window: Duration, options?: AlgorithmOptions): SlidingWindowAlgorithm ``` | Parameter | Type | Description | | --------------------- | ---------- | --------------------------------------------------------------------------- | | `limit` | `number` | Max requests in the sliding window | | `window` | `Duration` | Window length | | `options.shards` | `number` | Write distribution shards (default `1`). The budget is dealt across shards. | | `options.maxReserved` | `number` | Max tokens that can be borrowed | }> **Note:** `reserve` is not supported with sliding window. The algorithm needs both current and previous window counts, which makes reservation impractical. Ratelimit.tokenBucket(refillRate, interval, maxTokens, options?) [#ratelimittokenbucketrefillrate-interval-maxtokens-options] ```ts tokenBucket(refillRate: number, interval: Duration, maxTokens: number, options?: AlgorithmOptions): TokenBucketAlgorithm ``` | Parameter | Type | Description | | --------------------- | ---------- | --------------------------------------------------------------------------- | | `refillRate` | `number` | Tokens added per interval | | `interval` | `Duration` | Refill interval | | `maxTokens` | `number` | Maximum bucket capacity | | `options.shards` | `number` | Write distribution shards (default `1`). The budget is dealt across shards. | | `options.maxReserved` | `number` | Max tokens that can be borrowed from future refills | Core methods [#core-methods] limit(identifier, options?) [#limitidentifier-options] Consume tokens and return a response. This is the primary method for enforcing rate limits. ```ts limit(identifier: string, options?: LimitRequest): Promise ``` check(identifier, options?) [#checkidentifier-options] Evaluate without consuming tokens. Use this for read-only checks (e.g. showing a warning before the user submits). It runs the same evaluation as `limit()` for the requested `count`, so `check()` and a following `limit()` agree — it just never writes state. ```ts check(identifier: string, options?: CheckRequest): Promise ``` getRemaining(identifier) [#getremainingidentifier] Return the remaining tokens, reset time, and limit for an identifier. It reads every raw shard at one common timestamp and sums each shard's independently usable whole tokens. It never lets a full shard absorb another shard's refill, nets reserved debt against an open peer, or combines unusable fractions across isolated shards. ```ts getRemaining(identifier: string): Promise ``` getValue(identifier, options?) [#getvalueidentifier-options] Return a raw snapshot for custom projections and UI calculations. ```ts getValue(identifier: string, options?: { sampleShards?: number }): Promise ``` resetUsedTokens(identifier) [#resetusedtokensidentifier] Clear all stored state for an identifier. Useful for admin resets. ```ts resetUsedTokens(identifier: string): Promise ``` setDynamicLimit(options) [#setdynamiclimitoptions] Override the configured limit at runtime. Pass `{ limit: false }` to remove the override. Requires `dynamicLimits: true`. ```ts setDynamicLimit(options: { limit: number | false }): Promise ``` getDynamicLimit() [#getdynamiclimit] Read the current dynamic override. Returns `{ dynamicLimit: number | null }`. Requires `dynamicLimits: true`. ```ts getDynamicLimit(): Promise ``` hookAPI(options?) [#hookapioptions] Export a `getRatelimit` query and `getServerTime` mutation for the React hook. ```ts hookAPI(options?: HookAPIOptions): { getRatelimit: FunctionReference<'query'>; getServerTime: FunctionReference<'mutation'>; } ``` Request options [#request-options] LimitRequest [#limitrequest] Pass these options to `limit()` to customize behavior per-call. | Field | Type | Default | Description | | ----------- | --------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | | `rate` | `number` | `1` | Alias for `count`. Tokens to consume. | | `count` | `number` | `1` | Tokens to consume. Takes precedence if both `rate` and `count` are set. | | `reserve` | `boolean` | `false` | Allow borrowing from future capacity. `maxReserved` caps the debt when configured; otherwise headroom is uncapped. Not supported by `slidingWindow`. | | `ip` | `string` | — | IP address for deny-list matching | | `userAgent` | `string` | — | User-agent for deny-list matching | | `country` | `string` | — | Country code for deny-list matching | | `geo` | `unknown` | — | Reserved for future geo-based rules | CheckRequest [#checkrequest] Same fields as `LimitRequest`. `count` / `rate` set how many tokens the check is evaluated against, and `reserve` decides whether reserved capacity counts as available. Nothing is written, because `check()` is read-only. Response types [#response-types] RatelimitResponse [#ratelimitresponse] Returned by `limit()` and `check()`. | Field | Type | Description | | ------------- | -------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `success` | `boolean` | `true` if the request was allowed | | `ok` | `boolean` | Alias for `success` (Convex DX parity) | | `limit` | `number` | Maximum tokens for this algorithm | | `remaining` | `number` | Tokens left after this request (floored to 0). With `shards > 1` this is extrapolated from the stored capacity of the shard that served the request, so it is an estimate — use `getRemaining()` for an exact count. | | `reset` | `number` | Epoch ms when tokens will be available. Denied reserved fixed-window and token-bucket requests use the point where the request fits within `maxReserved`, not full debt recovery. Permanently oversized requests return `0`. | | `pending` | `Promise` | Resolves when async side-effects complete | | `reason` | `'timeout' \| 'cacheBlock' \| 'denyList' \| 'requestTooLarge'` | Present when a reason applies. `requestTooLarge` means no shard can ever serve the requested count; smaller unusable shards do not contribute retry deadlines. Reduce `count`, reduce `shards`, or raise capacity. Note: `failureMode: 'open'` can return `success: true` with `reason: 'timeout'`. | | `deniedValue` | `string` | Present only when `reason === 'denyList'`. The value that triggered the block. | RemainingResponse [#remainingresponse] Returned by `getRemaining()`. | Field | Type | Description | | ----------- | -------- | ------------------------------ | | `remaining` | `number` | Tokens available | | `reset` | `number` | Epoch ms of next replenishment | | `limit` | `number` | Maximum tokens | RatelimitSnapshot [#ratelimitsnapshot] Returned by `getValue()`. Used for custom projections and the React hook. | Field | Type | Description | | -------- | ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `value` | `number` | Tokens left at one common read timestamp across the stored capacity. Sampled shards are summed, then scaled up by the capacity share they cover | | `ts` | `number` | Timestamp of last state update | | `shard` | `number` | The sampled shard holding the most tokens | | `config` | `ResolvedAlgorithm` | Full algorithm config for `calculateRatelimit()` | | `state` | `RatelimitState` | Projected aggregate plus every sampled shard state. Sliding windows retain current and previous counts with both timestamps. All-shard snapshots preserve independent saturation and decay in later projections; partial samples remain estimates. | Pass a snapshot through `snapshotToState()` before handing it to `calculateRatelimit()`, which expects stored state rather than remaining tokens. Hook API [#hook-api] HookAPIOptions [#hookapioptions-1] Options for `hookAPI()`. | Field | Type | Default | Description | | -------------- | ----------------------------------------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------- | | `identifier` | `string \| (ctx, fromClient?) => string \| Promise` | — | How to resolve the identifier. A string uses it directly. A callback receives the Convex context and the optional client-provided identifier. | | `sampleShards` | `number` | `1` | How many shards to sample when reading. Higher = more accurate, more reads. | UseRatelimitOptions [#useratelimitoptions] Options for the `useRatelimit()` React hook. ```ts useRatelimit( getRatelimitValueQuery: FunctionReference<'query'> | string, options?: UseRatelimitOptions ) ``` | Field | Type | Default | Description | | ----------------------- | ----------------------------- | ------- | ------------------------------------------------------- | | `identifier` | `string` | — | Passed to the `getRatelimit` query | | `count` | `number` | `1` | Tokens to project for status calculation | | `sampleShards` | `number` | — | Override sampleShards from hook API | | `getServerTimeMutation` | `FunctionReference \| string` | — | Enables clock-skew correction between client and server | Time constants [#time-constants] Pre-defined millisecond constants exported from `kitcn/ratelimit`: | Constant | Value | | -------- | ------------- | | `SECOND` | `1_000` | | `MINUTE` | `60_000` | | `HOUR` | `3_600_000` | | `DAY` | `86_400_000` | | `WEEK` | `604_800_000` | Internal tables [#internal-tables] The rate limiter stores state in three local schema keys. These come from your local `convex/lib/plugins/ratelimit/schema.ts` extension, so do not define these keys twice. The underlying Convex storage table names stay underscored. | Schema key | Purpose | | ------------------------ | --------------------------------------------- | | `ratelimitState` | Per-identifier, per-shard token state | | `ratelimitDynamicLimit` | Dynamic limit overrides per prefix | | `ratelimitProtectionHit` | Protection tracking (hits, blocks) per prefix | Advanced notes [#advanced-notes] calculateRatelimit [#calculateratelimit] The `calculateRatelimit` function is exported for custom projections and UI calculations. It takes a state snapshot, algorithm config, current timestamp, and count, and returns the evaluated result without touching the database. ```ts showLineNumbers import { calculateRatelimit } from 'kitcn/ratelimit'; const result = calculateRatelimit( { value: 8, ts: Date.now() - 30_000 }, Ratelimit.fixedWindow(10, '1 m'), Date.now(), 1 ); // result.remaining, result.reset, result.retryAfter ``` `result.remaining` is floored to `0`; `result.remainingRaw` keeps the exact value and goes negative when the request overdraws. snapshotToState [#snapshottostate] `getValue()` reports tokens left, while `calculateRatelimit()` takes stored state — the two differ for sliding windows, which store current and previous used counts. The snapshot retains the projected aggregate and sampled shard states, and `snapshotToState` returns them for later calculations. Sampling every shard preserves independent capacity saturation and sliding-window decay; partial samples remain estimates. ```ts showLineNumbers import { calculateRatelimit, snapshotToState } from 'kitcn/ratelimit'; const snapshot = await limiter.getValue('user_123'); const result = calculateRatelimit( snapshotToState(snapshot), snapshot.config, Date.now(), 1 ); ``` Sharding [#sharding] When `shards > 1`, the configured budget is dealt across the shards and each `limit()` call picks a random shard (or two, using power-of-two-choices when `shards >= 3`) to reduce write contention. The shares add back up to the budget you configured, so `fixedWindow(20, '1 m', { shards: 8 })` still grants 20 requests per minute. Budgets that do not divide evenly hand the remainder to the low-numbered shards — `limit: 5` over two shards becomes `3` and `2`, not `2.5` each, because a shard can only spend whole tokens. Fractional totals deal their whole portion first and retain the fraction on one shard. Whole-token `maxReserved` headroom is dealt the same way. Token buckets allocate `refillRate` in proportion to each shard's `maxTokens` share, so uneven capacities refill without clipping the configured total. Two trade-offs come with it: * `getValue()` samples a subset of shards and scales the result up, so partial samples are estimates. Sampling every shard preserves independent state for exact later projections. `getRemaining()` reads every shard and is exact. * A single call spends from one shard, so `count` can never exceed that shard's share. Keep the per-shard share comfortably above your largest `count` — ten or more is a good target. The algorithm builders throw when `limit / shards` (or `capacity / shards`, or `maxTokens / shards` for `tokenBucket`) drops below `1`, since a shard holding less than one token cannot serve a request. * The limiter tries its preferred shard candidates first, then reads untouched candidates concurrently before denying a request. Extra reads happen only when the preferred candidates cannot serve it. For most use cases, `shards: 1` (the default) is fine. Increase shards only when you see write contention on hot identifiers. Ephemeral cache [#ephemeral-cache] The ephemeral block cache is an in-memory `Map` that caches "blocked until" timestamps per shard, requested count, and reservation mode. When a shard fails, equivalent calls skip its database read until the block expires and try another shard; smaller or reserved requests still probe it. A request is cache-blocked only when every shard it could use is blocked, and `reset` is the earliest retry across cached and freshly evaluated shards. Cache writes prune expired variants, skip permanent infinite resets, and retain at most 32 variants per identifier. The cache is per-`Ratelimit` instance and resets on each Convex function invocation. Pass `ephemeralCache: false` to disable it, or pass a shared `Map` across multiple `Ratelimit` instances to share the cache. ok alias [#ok-alias] The response includes both `success` and `ok`. They are always identical. `ok` exists for Convex DX parity with patterns like `if (!result.ok) throw ...`. Next steps [#next-steps] # Resend (/docs/plugins/resend) `@kitcn/resend` is the Resend plugin for kitcn. What You Get [#what-you-get] * Durable queueing and batching to Resend `/emails/batch` * Retries with backoff for transient failures * Idempotency-key handling * Rate pacing for batch calls * Webhook ingestion and status tracking 1. Install [#1-install] 2. Scaffold User-Owned Files [#2-scaffold-user-owned-files] ```bash showLineNumbers npx kitcn add resend ``` Default scaffold set: * `convex/lib/plugins/resend/schema.ts` * `convex/functions/plugins/resend.ts` * `convex/functions/plugins/email.tsx` * `convex/lib/plugins/resend/plugin.ts` * `convex/lib/plugins/resend/webhook.ts` * `convex/lib/plugins/resend/crons.ts` 3. Register In Schema [#3-register-in-schema] ```ts title="convex/functions/schema.ts" showLineNumbers {2,6} import { defineSchema } from 'kitcn/orm'; import { resendExtension } from '../lib/plugins/resend/schema'; export default defineSchema(tables).extend(resendExtension()); ``` `kitcn add resend` writes this for you. Customize resend tables and relations by editing `convex/lib/plugins/resend/schema.ts` directly. If `paths.env` is missing, `kitcn add resend` also creates `convex/lib/get-env.ts`, writes `paths.env` into `kitcn.json`, and adds: * `RESEND_API_KEY: z.string().optional()` * `RESEND_WEBHOOK_SECRET: z.string().optional()` * `RESEND_FROM_EMAIL: z.string().optional()` The add command also reminds you to set `RESEND_API_KEY` in `convex/.env` before sending real email. 4. Generate Framework Files [#4-generate-framework-files] ```bash showLineNumbers npx kitcn codegen ``` If you scaffold `email.tsx`, install React Email deps: 5. Configure Middleware Once [#5-configure-middleware-once] ```ts title="convex/lib/plugins/resend/plugin.ts" showLineNumbers import { ResendPlugin } from '@kitcn/resend'; import { getEnv } from '../../get-env'; export const resend = ResendPlugin.configure({ apiKey: getEnv().RESEND_API_KEY, webhookSecret: getEnv().RESEND_WEBHOOK_SECRET, initialBackoffMs: 30_000, retryAttempts: 5, testMode: true, }); ``` `testMode` defaults to `true`; only Resend test recipients are allowed until you set `testMode: false`. 6. Use In cRPC Procedures [#6-use-in-crpc-procedures] Use middleware for config/context and call scaffolded internal functions through generated callers. ```ts title="convex/functions/sendEmail.ts" showLineNumbers import { createPluginsResendCaller } from './generated/plugins/resend.runtime'; import { privateMutation } from '../lib/crpc'; import { resend } from '../lib/plugins/resend/plugin'; export const sendTestEmail = privateMutation.use(resend.middleware()) .mutation(async ({ ctx }) => { const caller = createPluginsResendCaller(ctx); await caller.sendEmail({ from: 'Me ', to: ['delivered@resend.dev'], subject: 'Hi there', html: 'This is a test email', }); }); ``` 7. Generic React Email Action [#7-generic-react-email-action] `convex/functions/plugins/email.tsx` is a generic internal cRPC action scaffold: * Input: `to, from?, subject, title, body, ctaLabel?, ctaUrl?` * Sender resolution: `from ?? getEnv().RESEND_FROM_EMAIL` * Delivery: `createPluginsResendCaller(ctx).sendEmail(...)` Use it from auth organization invites: ```ts title="convex/functions/auth.ts" showLineNumbers await actionCtx.scheduler.runAfter( 0, internal.plugins.email.sendTemplatedEmail, { to: data.email, subject: `${inviterName} invited you to join ${organizationName}`, title: `Invitation to join ${organizationName}`, body: `${inviterName} (${data.inviter.user.email}) invited you to join ${organizationName}${roleSuffix}.`, ctaLabel: 'Accept invitation', ctaUrl: acceptUrl, } ); ``` 8. Wire Webhook Route [#8-wire-webhook-route] ```ts title="convex/functions/http.ts" showLineNumbers import { router } from '../lib/crpc'; import { resendWebhook } from '../lib/plugins/resend/webhook'; import { health } from '../routers/health'; export const httpRouter = router({ health, resendWebhook, }); ``` 9. Optional Event Fanout [#9-optional-event-fanout] `convex/functions/plugins/resend.ts` includes scaffolded `onEmailEvent`. Customize it directly. 10. Optional Cleanup Crons [#10-optional-cleanup-crons] ```ts title="convex/functions/crons.ts" showLineNumbers export { default } from '../lib/plugins/resend/crons'; ``` Runtime Surface [#runtime-surface] `convex/functions/plugins/resend.ts` contains the plugin internal procedures: * `sendEmail` * `createManualEmail` * `updateManualEmail` * `cancelEmail` * `getStatus` * `get` * `handleEmailEvent` * `cleanupOldEmails` * `cleanupAbandonedEmails` Next Steps [#next-steps] # Admin (/docs/auth/plugins/admin) import { InfoIcon } from "lucide-react" Overview [#overview] Admin features built on [Better Auth's admin plugin](https://www.better-auth.com/docs/plugins/admin): | Feature | Description | | ------------------ | ----------------------------------------- | | Role-based access | Admin/user roles with middleware checking | | User management | Create, update, delete users | | Banning | Ban/unban users with expiration | | Session management | List, revoke user sessions | | Impersonation | Admin can impersonate users | | Custom permissions | Granular access control beyond roles | Prerequisites [#prerequisites] Ensure you have [Auth Server](/docs/auth/server) set up before adding admin features. 1. Server Configuration [#1-server-configuration] Add the admin plugin to your auth options: ```ts title="convex/functions/auth.ts" showLineNumbers {2,5-13} import { admin } from 'better-auth/plugins'; import { defineAuth } from './generated/auth'; export default defineAuth((ctx) => ({ // ... existing config plugins: [ admin({ defaultRole: 'user', // adminUserIds: ['user_id_1'], // Always admin regardless of role // impersonationSessionDuration: 60 * 60, // 1 hour default // defaultBanReason: 'No reason', // bannedUserMessage: 'You have been banned', }), // ... other plugins ], })); ``` Admin Assignment via Environment [#admin-assignment-via-environment] Configure admin emails in your environment: ```bash title="convex/.env" showLineNumbers ADMIN=admin@example.com,other@example.com ``` Assign admin role on user creation: ```ts title="convex/functions/auth.ts" showLineNumbers {1,4-15} import { defineAuth } from './generated/auth'; export default defineAuth((ctx) => ({ // ... config triggers: { user: { create: { before: async (data, triggerCtx) => { const env = getEnv(); const adminEmails = env.ADMIN; const role = data.role !== 'admin' && adminEmails?.includes(data.email) ? 'admin' : data.role; return { data: { ...data, role } }; }, }, }, }, })); ``` 2. Client Configuration [#2-client-configuration] Add the admin client plugin: ```ts title="src/lib/convex/auth-client.ts" showLineNumbers {1,6} import { adminClient } from 'better-auth/client/plugins'; export const authClient = createAuthClient({ // ... existing config plugins: [ adminClient(), // ... other plugins ], }); ``` 3. Schema [#3-schema] Add admin fields to your user table: ```ts title="convex/functions/schema.ts" showLineNumbers {1-2,4-18} import { boolean, convexTable, defineSchema, integer, text } from 'kitcn/orm'; export const user = convexTable('user', { // ... existing fields role: text(), // 'admin' | 'user' banned: boolean(), banReason: text(), banExpires: integer(), }); export const session = convexTable('session', { // ... existing fields impersonatedBy: text(), // Admin user ID }); export const tables = { user, session }; export default defineSchema(tables, { strict: false }); ``` 4. Access Control [#4-access-control] Role Middleware [#role-middleware] Add role middleware to your cRPC setup: ```ts title="convex/lib/crpc.ts" showLineNumbers {1-4,7-14,16-24} const c = initCRPC .meta<{ auth?: 'optional' | 'required'; role?: 'admin' }>() .create(); /** Role middleware - checks admin role from meta after auth middleware */ const roleMiddleware = c.middleware(({ ctx, meta, next }) => { const user = (ctx as { user?: { role?: string | null } }).user; if (meta.role === 'admin' && user?.role !== 'admin') { throw new CRPCError({ code: 'FORBIDDEN', message: 'Admin access required', }); } return next({ ctx }); }); export const authQuery = c.query .meta({ auth: 'required' }) .use(devMiddleware) .use(authMiddleware) .use(roleMiddleware); export const authMutation = c.mutation .meta({ auth: 'required' }) .use(devMiddleware) .use(authMiddleware) .use(roleMiddleware) .use(ratelimit.middleware()); ``` Role Guard Helper [#role-guard-helper] ```ts title="convex/lib/auth/role-guard.ts" showLineNumbers {4-7,8-12} import { CRPCError } from 'kitcn/server'; export function roleGuard( role: 'admin', user: { role?: string | null } | null ) { if (!user) { throw new CRPCError({ code: 'FORBIDDEN', message: 'Access denied', }); } if (role === 'admin' && user.role !== 'admin') { throw new CRPCError({ code: 'FORBIDDEN', message: 'Admin access required', }); } } ``` Custom Access Control [#custom-access-control] Define granular permissions beyond admin/user roles: ```ts title="convex/shared/permissions.ts" showLineNumbers {1-2,4-7,9-13,15-17} import { createAccessControl } from 'better-auth/plugins/access'; import { defaultStatements, adminAc } from 'better-auth/plugins/admin/access'; const statement = { ...defaultStatements, project: ['create', 'read', 'update', 'delete'], } as const; export const ac = createAccessControl(statement); export const admin = ac.newRole({ ...adminAc.statements, project: ['create', 'read', 'update', 'delete'], }); export const user = ac.newRole({ project: ['create', 'read'], }); ``` Pass to plugins: ```ts title="convex/functions/auth.ts" showLineNumbers {2,5} // Server admin({ ac, roles: { admin, user } }) // Client (src/lib/convex/auth-client.ts) adminClient({ ac, roles: { admin, user } }) ``` Admin Functions [#admin-functions] }> Function examples use the ORM (`ctx.orm`). Check Admin Status [#check-admin-status] ```ts title="convex/functions/admin.ts" showLineNumbers {3-4,7-10,13-21} import * as z from 'zod'; import { authQuery } from '../lib/crpc'; export const checkUserAdminStatus = authQuery .meta({ role: 'admin' }) .input(z.object({ userId: z.string() })) .output( z.object({ isAdmin: z.boolean(), role: z.string().nullish(), }) ) .query(async ({ ctx, input }) => { const user = await ctx.orm.query.user.findFirstOrThrow({ where: { id: input.userId }, }); return { isAdmin: user.role === 'admin', role: user.role, }; }); ``` Build additional admin functions (update role, grant admin, list users, dashboard stats) following this pattern. The key conventions: * Gate admin functions with `.meta({ role: 'admin' })` to trigger role middleware * Use `ctx.orm` for user reads and role updates * Use `.paginated()` for list endpoints See the [example app](https://github.com/nichochar/kitcn/tree/main/example) for complete admin CRUD implementations. Client Usage [#client-usage] React Hooks [#react-hooks] ```tsx title="src/components/admin-check.tsx" showLineNumbers {1,4-5,7-9} import { authClient } from '@/lib/convex/auth-client'; function AdminPanel() { const { data: session } = authClient.useSession(); const isAdmin = session?.user?.role === 'admin'; if (!isAdmin) { return
Access denied
; } return (
{/* Admin controls */}
); } ``` Ban/Unban Users [#banunban-users] ```tsx showLineNumbers {2-6,9-11} // Ban user await authClient.admin.banUser({ userId: 'user_123', banReason: 'Violation of terms', banExpiresIn: 60 * 60 * 24 * 7, // 7 days }); // Unban user await authClient.admin.unbanUser({ userId: 'user_123', }); ``` Session Management [#session-management] ```tsx showLineNumbers {2-4,7-9,12-14} // List user sessions const { data: sessions } = await authClient.admin.listUserSessions({ userId: 'user_123', }); // Revoke specific session await authClient.admin.revokeUserSession({ sessionToken: 'session_token', }); // Revoke all sessions await authClient.admin.revokeUserSessions({ userId: 'user_123', }); ``` Impersonation [#impersonation] ```tsx showLineNumbers {2-4,7} // Start impersonating user await authClient.admin.impersonateUser({ userId: 'user_123', }); // Stop impersonating await authClient.admin.stopImpersonating(); ``` User Management [#user-management] ```tsx showLineNumbers {2-8,11-18,21,24,27,30} // Create user await authClient.admin.createUser({ email: 'user@example.com', password: 'password', name: 'John Doe', role: 'user', }); // List users (with filtering/sorting/pagination) const { users, total } = await authClient.admin.listUsers({ searchValue: 'john', searchField: 'name', limit: 20, offset: 0, sortBy: 'createdAt', sortDirection: 'desc', }); // Set role await authClient.admin.setRole({ userId, role: 'admin' }); // Set password await authClient.admin.setUserPassword({ userId, newPassword }); // Update user await authClient.admin.updateUser({ userId, data: { name: 'New Name' } }); // Delete user await authClient.admin.removeUser({ userId }); ``` Permission Checking [#permission-checking] ```tsx showLineNumbers {2-4,7-10} // Check if current user has permission (server call) const { success } = await authClient.admin.hasPermission({ permissions: { project: ['create', 'update'] }, }); // Check role permission (client-side, no server call) const canDelete = authClient.admin.checkRolePermission({ role: 'admin', permissions: { project: ['delete'] }, }); ``` API Reference [#api-reference] | Operation | Method | Admin Required | | --------------------- | -------------------------------------- | -------------- | | Create user | `authClient.admin.createUser` | Yes | | List users | `authClient.admin.listUsers` | Yes | | Set role | `authClient.admin.setRole` | Yes | | Set password | `authClient.admin.setUserPassword` | Yes | | Update user | `authClient.admin.updateUser` | Yes | | Ban user | `authClient.admin.banUser` | Yes | | Unban user | `authClient.admin.unbanUser` | Yes | | List sessions | `authClient.admin.listUserSessions` | Yes | | Revoke session | `authClient.admin.revokeUserSession` | Yes | | Revoke all sessions | `authClient.admin.revokeUserSessions` | Yes | | Impersonate | `authClient.admin.impersonateUser` | Yes | | Stop impersonating | `authClient.admin.stopImpersonating` | Yes | | Remove user | `authClient.admin.removeUser` | Yes | | Check permission | `authClient.admin.hasPermission` | No | | Check role permission | `authClient.admin.checkRolePermission` | No | }> Use Convex functions for custom admin operations. Use Better Auth client API for standard operations like user management, banning, and session management. Next Steps [#next-steps] # Anonymous (/docs/auth/plugins/anonymous) import { InfoIcon } from "lucide-react" Overview [#overview] Anonymous auth built on [Better Auth's anonymous plugin](https://www.better-auth.com/docs/plugins/anonymous): | Feature | Description | | ----------------------- | ------------------------------------------------------ | | Anonymous sign-in | Create authenticated sessions without credentials | | `isAnonymous` flag | Identify guest users in your schema | | Custom email domain | Control the synthetic email domain for anonymous users | | Custom name generation | Generate readable display names for guests | | Account linking | Upgrade anonymous users to real accounts | | Data migration callback | Copy domain data during the linking process | | Source user deletion | Automatically clean up anonymous records after linking | Prerequisites [#prerequisites] Ensure you have [Auth Server](/docs/auth/server) set up before adding anonymous features. 1. Server Configuration [#1-server-configuration] Add the anonymous plugin to your auth options. We'll configure all the key options — a custom email domain, a name generator, and a data migration callback: ```ts title="convex/functions/auth.ts" showLineNumbers {1,6-24} import { anonymous } from 'better-auth/plugins'; import { defineAuth } from './generated/auth'; export default defineAuth((ctx) => ({ plugins: [ anonymous({ emailDomainName: 'guest.yourapp.com', generateName: async () => `Guest-${Math.random().toString(36).slice(2, 10)}`, onLinkAccount: async ({ anonymousUser, newUser, ctx: linkCtx }) => { const sourceBio = typeof anonymousUser.user.bio === 'string' ? anonymousUser.user.bio.trim() : ''; const destinationBio = typeof newUser.user.bio === 'string' ? newUser.user.bio.trim() : ''; if (!sourceBio || destinationBio) return; await linkCtx.context.internalAdapter.updateUser(newUser.user.id, { bio: sourceBio, }); }, }), // ... other plugins ], })); ``` Here's what each option does: | Option | Type | Description | | ---------------------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------- | | `emailDomainName` | `string` | Domain for synthetic anonymous emails (e.g. `guest@guest.yourapp.com`). Defaults to `"better-auth.anonymous"` | | `generateName` | `() => Promise` | Async function that returns a display name for the anonymous user | | `onLinkAccount` | `(opts) => Promise` | Callback that runs when an anonymous user links to a real account. Use this to migrate domain data | | `disableDeleteAnonymousUser` | `boolean` | When `true`, keeps the anonymous user record after linking instead of deleting it | | `generateRandomEmail` | `(opts) => Promise` | Custom function to generate the full anonymous email address | }> The `emailDomainName` should be a domain you control or a clearly non-routable domain. This prevents accidental email delivery to synthetic addresses. 2. Client Configuration [#2-client-configuration] Add the anonymous client plugin so you can call `signIn.anonymous()` from your frontend: ```ts title="src/lib/convex/auth-client.ts" showLineNumbers {1,6} import { anonymousClient } from 'better-auth/client/plugins'; export const authClient = createAuthClient({ // ... existing config plugins: [ anonymousClient(), // ... other plugins ], }); ``` 3. Schema [#3-schema] Add the `isAnonymous` field to your user table so you can distinguish guest users from real accounts: ```ts title="convex/functions/schema.ts" showLineNumbers {1-2,6} import { boolean, convexTable, defineSchema, text } from 'kitcn/orm'; export const user = convexTable('user', { // ... existing fields role: text(), isAnonymous: boolean(), }); ``` Better Auth sets `isAnonymous` to `true` when creating anonymous users and clears it when the user links to a real account. Anonymous Sign-In [#anonymous-sign-in] Call `authClient.signIn.anonymous()` to create a guest session: ```ts showLineNumbers await authClient.signIn.anonymous({ fetchOptions: { throw: true }, }); ``` When this runs, Better Auth: 1. Creates a new user with `isAnonymous: true` 2. Generates a synthetic email using your `emailDomainName` (e.g. `abc123@guest.yourapp.com`) 3. Generates a display name using your `generateName` function 4. Creates an authenticated session — the user is now fully signed in The anonymous user gets a real session token, so all your auth middleware and permission checks work exactly the same as for regular users. Account Linking [#account-linking] The real power of anonymous auth is letting users try your app first, then upgrade to a real account without losing their work. Here's the flow: 1. User signs in anonymously → gets a session with `isAnonymous: true` 2. User decides to create a real account → calls `signUp.email` (or links a social provider) 3. Better Auth detects the existing anonymous session and links the accounts 4. Your `onLinkAccount` callback runs — this is where you migrate data 5. The anonymous user record is deleted (unless `disableDeleteAnonymousUser: true`) Here's the linking call from the client: ```ts showLineNumbers // User is currently signed in anonymously. // When they sign up, Better Auth links automatically. await authClient.signUp.email({ email: 'user@example.com', name: 'Real Name', password: 'secure-password', }); ``` The onLinkAccount Callback [#the-onlinkaccount-callback] This callback is your chance to migrate domain data from the anonymous user to the newly linked account. Here's the bio migration pattern from the example app: ```ts title="convex/functions/auth.ts" showLineNumbers {2-14} anonymous({ onLinkAccount: async ({ anonymousUser, newUser, ctx: linkCtx }) => { const sourceBio = typeof anonymousUser.user.bio === 'string' ? anonymousUser.user.bio.trim() : ''; const destinationBio = typeof newUser.user.bio === 'string' ? newUser.user.bio.trim() : ''; // Only copy if source has bio and destination doesn't if (!sourceBio || destinationBio) return; await linkCtx.context.internalAdapter.updateUser(newUser.user.id, { bio: sourceBio, }); }, }), ``` The callback receives: | Parameter | Description | | ----------------------------- | -------------------------------------------------- | | `anonymousUser.user` | The anonymous user record (source) | | `newUser.user` | The newly created/linked user record (destination) | | `ctx.context.internalAdapter` | Database adapter for updating user records | }> By default, the anonymous source user is **deleted** after linking. Set `disableDeleteAnonymousUser: true` if you need to keep anonymous records for auditing or compliance. Data Migration Patterns [#data-migration-patterns] The `onLinkAccount` callback is flexible. Here are common patterns for migrating domain data: Cart Migration [#cart-migration] ```ts showLineNumbers {2-7} onLinkAccount: async ({ anonymousUser, newUser, ctx: linkCtx }) => { // Move cart items from anonymous user to linked user await linkCtx.context.internalAdapter.updateMany('cart_items', { where: { userId: anonymousUser.user.id }, data: { userId: newUser.user.id }, }); }, ``` Draft Content Migration [#draft-content-migration] ```ts showLineNumbers {2-7} onLinkAccount: async ({ anonymousUser, newUser, ctx: linkCtx }) => { // Transfer draft posts/documents await linkCtx.context.internalAdapter.updateMany('drafts', { where: { authorId: anonymousUser.user.id }, data: { authorId: newUser.user.id }, }); }, ``` Profile Field Copy [#profile-field-copy] ```ts showLineNumbers {2-10} onLinkAccount: async ({ anonymousUser, newUser, ctx: linkCtx }) => { // Copy profile fields the anonymous user set const updates: Record = {}; if (anonymousUser.user.bio) updates.bio = anonymousUser.user.bio; if (anonymousUser.user.location) updates.location = anonymousUser.user.location; if (Object.keys(updates).length > 0) { await linkCtx.context.internalAdapter.updateUser(newUser.user.id, updates); } }, ``` What's Not Automatic [#whats-not-automatic] }> **Domain data migration is not automatic.** Better Auth handles the user/session linking, but your app's domain data — carts, drafts, quotas, profile fields — must be migrated in `onLinkAccount`. If you skip this callback, anonymous user data stays orphaned (or gets deleted with the anonymous record). }> **Compliance logging is not built-in.** If you need audit trails for anonymous-to-account conversions, add logging in your `onLinkAccount` callback or use proxy/HTTP logging separately. Client Usage [#client-usage] "Continue as Guest" Button [#continue-as-guest-button] The most common pattern is a guest button alongside your regular sign-in options. Here's how the example app does it with TanStack Query: ```tsx title="src/components/login-form.tsx" showLineNumbers {1,4-11,14-16,21-29} import { authClient } from '@/lib/convex/auth-client'; import { useMutation } from '@tanstack/react-query'; const signInAnonymous = useMutation({ mutationFn: async () => { await authClient.signIn.anonymous({ fetchOptions: { throw: true }, }); }, onSuccess: () => router.push('/'), }); // In your JSX: const handleAnonymousSignIn = () => { signInAnonymous.mutate(); }; return (
{/* Other sign-in buttons... */}
); ``` Checking Anonymous Status [#checking-anonymous-status] You can check whether the current user is anonymous to show upgrade prompts: ```tsx showLineNumbers {1,4-5,8-10} import { authClient } from '@/lib/convex/auth-client'; function UpgradePrompt() { const { data: session } = authClient.useSession(); const isAnonymous = session?.user?.isAnonymous; if (!isAnonymous) return null; return (
Create an account to save your progress.
); } ``` API Reference [#api-reference] | Operation | Method | Description | | ----------------- | ------------------------------- | -------------------------------------- | | Anonymous sign-in | `authClient.signIn.anonymous()` | Create a guest session | | Account linking | `authClient.signUp.email()` | Link anonymous user to email account | | Social linking | `authClient.signIn.social()` | Link anonymous user to social provider | | Check status | `session.user.isAnonymous` | Whether the current user is anonymous | Next Steps [#next-steps] # Organizations (/docs/auth/plugins/organizations) import { InfoIcon } from "lucide-react" Overview [#overview] Multi-tenant organization system with [Better Auth's organization plugin](https://www.better-auth.com/docs/plugins/organization): | Feature | Description | | -------------- | ----------------------------------- | | Organizations | Create, update, delete workspaces | | Members | Role-based access within orgs | | Invitations | Email invites with expiration | | Teams | Group members within orgs | | Access control | Custom permissions per role | | Hooks | Lifecycle events for all operations | Prerequisites [#prerequisites] Ensure you have [Auth Server](/docs/auth/server) set up before adding organizations. 1. Server Configuration [#1-server-configuration] Add the organization plugin to your auth options: ```ts title="convex/functions/auth.ts" showLineNumbers {3,7-33} import { organization } from 'better-auth/plugins'; import { requireSchedulerCtx } from 'kitcn/server'; import { defineAuth } from './generated/auth'; export default defineAuth((ctx) => ({ // ... existing config plugins: [ convex({ authConfig, jwks: process.env.JWKS }), admin(), organization({ ac, roles, allowUserToCreateOrganization: true, organizationLimit: 5, membershipLimit: 100, creatorRole: 'owner', invitationExpiresIn: 48 * 60 * 60, // 48 hours teams: { enabled: true, maximumTeams: 10, }, sendInvitationEmail: async (data) => { const schedulerCtx = requireSchedulerCtx(ctx); const inviterName = data.inviter.user.name || 'Team Admin'; const organizationName = data.organization.name; const roleSuffix = data.role ? ` as ${data.role}` : ''; const acceptUrl = `${process.env.SITE_URL!}/w/${data.organization.slug}?invite=${data.id}`; await schedulerCtx.scheduler.runAfter( 0, internal.plugins.email.sendTemplatedEmail, { to: data.email, subject: `${inviterName} invited you to join ${organizationName}`, title: `Invitation to join ${organizationName}`, body: `${inviterName} (${data.inviter.user.email}) invited you to join ${organizationName}${roleSuffix}.`, ctaLabel: 'Accept invitation', ctaUrl: acceptUrl, } ); }, }), ], })); ``` `sendInvitationEmail` can run from mutation-driven auth flows. Use `requireSchedulerCtx(ctx)` when you need scheduling, then schedule the follow-up work. Do not narrow to `ActionCtx` unless the callback truly runs only inside an action. 2. Client Configuration [#2-client-configuration] Add the organization client plugin: ```ts title="src/lib/convex/auth-client.ts" showLineNumbers {1-2,7-11} import { organizationClient } from 'better-auth/client/plugins'; import { ac, roles } from '@convex/auth-shared'; export const authClient = createAuthClient({ // ... existing config plugins: [ inferAdditionalFields(), convexClient(), organizationClient({ ac, roles, teams: { enabled: true }, }), ], }); ``` 3. Schema [#3-schema] Add organization tables to your schema: ```ts title="convex/functions/schema.ts" showLineNumbers {1-2,4-68} import { convexTable, defineSchema, id, index, integer, json, text, timestamp, } from 'kitcn/orm'; export const organization = convexTable( 'organization', { name: text().notNull(), slug: text().notNull(), logo: text(), createdAt: timestamp().notNull().defaultNow(), metadata: json>(), }, (t) => [index('slug').on(t.slug), index('name').on(t.name)] ); export const member = convexTable( 'member', { organizationId: id('organization').notNull(), userId: id('user').notNull(), role: text().notNull(), createdAt: timestamp().notNull().defaultNow(), }, (t) => [ index('userId').on(t.userId), index('organizationId').on(t.organizationId), index('organizationId_userId').on(t.organizationId, t.userId), index('organizationId_role').on(t.organizationId, t.role), ] ); export const invitation = convexTable( 'invitation', { organizationId: id('organization').notNull(), inviterId: id('user').notNull(), email: text().notNull(), role: text(), status: text().notNull(), expiresAt: integer().notNull(), createdAt: timestamp().notNull().defaultNow(), }, (t) => [ index('email').on(t.email), index('status').on(t.status), index('organizationId').on(t.organizationId), index('email_organizationId_status').on(t.email, t.organizationId, t.status), index('organizationId_status').on(t.organizationId, t.status), ] ); // Add these fields to your existing session table export const session = convexTable('session', { // ... existing session fields activeOrganizationId: id('organization'), activeTeamId: id('team'), }); export const tables = { organization, member, invitation, session }; export default defineSchema(tables, { strict: false }); ``` Teams (Optional) [#teams-optional] ```ts title="convex/functions/schema.ts" showLineNumbers {1-2,4-26} import { convexTable, id, index, integer, text, timestamp } from 'kitcn/orm'; export const team = convexTable( 'team', { name: text().notNull(), organizationId: id('organization').notNull(), createdAt: timestamp().notNull().defaultNow(), updatedAt: integer(), }, (t) => [index('organizationId').on(t.organizationId)] ); export const teamMember = convexTable( 'teamMember', { teamId: id('team').notNull(), userId: id('user').notNull(), createdAt: timestamp(), }, (t) => [ index('teamId').on(t.teamId), index('userId').on(t.userId), index('teamId_userId').on(t.teamId, t.userId), ] ); ``` Additional Fields [#additional-fields] Add custom fields to organization tables: ```ts title="convex/functions/auth.ts" showLineNumbers {2-18} organization({ schema: { organization: { fields: { description: v.optional(v.string()), website: v.optional(v.string()), industry: v.optional(v.string()), size: v.optional(v.string()), }, }, member: { fields: { title: v.optional(v.string()), department: v.optional(v.string()), }, }, invitation: { fields: { message: v.optional(v.string()), }, }, }, }), ``` Then update your schema to include these fields: ```ts title="convex/functions/schema.ts" showLineNumbers {1-2,4-26} import { convexTable, text } from 'kitcn/orm'; export const organization = convexTable('organization', { // ... existing fields description: text(), website: text(), industry: text(), size: text(), }); export const member = convexTable('member', { // ... existing fields title: text(), department: text(), }); export const invitation = convexTable('invitation', { // ... existing fields message: text(), }); ``` 4. Access Control [#4-access-control] Define roles and permissions using Better Auth's access control system. Basic Setup [#basic-setup] ```ts title="convex/shared/auth-shared.ts" showLineNumbers {1-5,7-11,13-19} import { createAccessControl } from 'better-auth/plugins/access'; import { defaultStatements, memberAc, ownerAc, } from 'better-auth/plugins/organization/access'; const statement = { ...defaultStatements, // Add custom resources as needed } as const; export const ac = createAccessControl(statement); const member = ac.newRole({ ...memberAc.statements, }); const owner = ac.newRole({ ...ownerAc.statements, }); export const roles = { member, owner }; ``` Custom Permissions [#custom-permissions] Define custom resources and permissions beyond the defaults: ```ts title="convex/shared/auth-shared.ts" showLineNumbers {1-7,11-14,16-21,23-29} const statement = { ...defaultStatements, // Custom resources project: ['create', 'read', 'update', 'delete'], billing: ['read', 'update'], analytics: ['read'], } as const; export const ac = createAccessControl(statement); // Custom roles with granular permissions const viewer = ac.newRole({ project: ['read'], analytics: ['read'], }); const editor = ac.newRole({ ...memberAc.statements, project: ['create', 'read', 'update'], analytics: ['read'], }); const admin = ac.newRole({ ...ownerAc.statements, project: ['create', 'read', 'update', 'delete'], billing: ['read', 'update'], analytics: ['read'], }); export const roles = { viewer, editor, admin }; ``` Check Role Permissions [#check-role-permissions] ```ts showLineNumbers {2-5} // Check if a role has specific permission const canEdit = ac.checkRolePermission({ role: 'editor', permission: { project: ['update'] }, }); ``` Dynamic Access Control [#dynamic-access-control] For runtime role creation (e.g., user-defined roles): ```ts title="convex/functions/auth.ts" showLineNumbers {2-16} organization({ ac: { ...ac, resolveRole: async ({ role, organizationId }) => { // Check if it's a built-in role if (roles[role]) return roles[role]; // Fetch custom role from database const customRole = await ctx.orm.query.customRole.findFirst({ where: { name: role, organizationId }, }); if (customRole) { return ac.newRole(customRole.permissions); } return null; }, }, }), ``` Permission Helper [#permission-helper] ```ts title="convex/lib/auth/auth-helpers.ts" showLineNumbers {1-2,4-7,9-17} import { CRPCError } from 'kitcn/server'; import type { AuthCtx } from '../crpc'; export const hasPermission = async ( ctx: AuthCtx, body: { permissions: Record }, shouldThrow = true ) => { const result = await ctx.auth.api.hasPermission({ body, headers: ctx.auth.headers, }); if (shouldThrow && !result.success) { throw new CRPCError({ code: 'FORBIDDEN', message: 'Insufficient permissions', }); } return result.success; }; ``` Check Permission (Client) [#check-permission-client] ```tsx showLineNumbers {2-4,6-8} // Check permission before showing UI const { data } = await authClient.organization.hasPermission({ permissions: { project: ['delete'] }, }); if (data?.success) { // Show delete button } ``` Organization Functions [#organization-functions] **Pattern:** Use Better Auth API for multi-table operations (create, delete, invitations). Use `ctx.orm` for simple reads/updates. }> Function examples use the ORM via `ctx.orm`. Check Slug Availability [#check-slug-availability] Before creating an organization, validate the slug is unique: ```ts title="convex/functions/organization.ts" showLineNumbers {1-5} export const checkSlug = authQuery .input(z.object({ slug: z.string() })) .output(z.object({ available: z.boolean() })) .query(async ({ ctx, input }) => { const existing = await ctx.orm.query.organization.findFirst({ where: { slug: input.slug }, }); return { available: !existing }; }); ``` ```tsx title="Client usage" showLineNumbers {1-4} const { data } = await authClient.organization.checkSlug({ slug: 'my-org' }); if (!data.available) { // Show error: slug already taken } ``` List Organizations [#list-organizations] ```ts title="convex/functions/organization.ts" showLineNumbers {1-3,6-9,12-24,27-31} export const listOrganizations = authQuery .output( z.object({ canCreateOrganization: z.boolean(), organizations: z.array( z.object({ id: z.string(), createdAt: z.date(), isPersonal: z.boolean(), logo: z.string().nullish(), name: z.string(), plan: z.string(), slug: z.string(), }) ), }) ) .query(async ({ ctx }) => { const orgs = await listUserOrganizations(ctx, ctx.userId); if (!orgs || orgs.length === 0) { return { canCreateOrganization: true, organizations: [] }; } const activeOrgId = ctx.user.activeOrganization?.id; const organizations = orgs .filter((org) => org.id !== activeOrgId) .map((org) => ({ id: org.id, createdAt: org.createdAt, isPersonal: org.id === ctx.user.personalOrganizationId, logo: org.logo || null, name: org.name, plan: DEFAULT_PLAN, slug: org.slug, })); return { canCreateOrganization: true, organizations }; }); ``` Create Organization [#create-organization] ```ts title="convex/functions/organization.ts" showLineNumbers {1-4,7-18,21-33,36-43} export const createOrganization = authMutation .meta({ ratelimit: 'organization/create' }) .input(z.object({ name: z.string().min(1).max(100) })) .output(z.object({ id: z.string(), slug: z.string() })) .mutation(async ({ ctx, input }) => { let slug = input.name; let attempt = 0; while (attempt < 10) { const existingOrg = await ctx.orm.query.organization.findFirst({ where: { slug }, }); if (!existingOrg) break; slug = `${slug}-${Math.random().toString(36).slice(2, 10)}`; attempt++; } if (attempt >= 10) { throw new CRPCError({ code: 'BAD_REQUEST', message: 'Could not generate a unique slug. Please provide a custom slug.', }); } const org = await ctx.auth.api.createOrganization({ body: { monthlyCredits: 0, name: input.name, slug }, headers: ctx.auth.headers, }); if (!org) { throw new CRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Failed to create organization', }); } await setActiveOrganizationHandler(ctx, { organizationId: org.id }); return { id: org.id, slug: org.slug }; }); ``` Build additional organization, invitation, and member functions following this pattern. The key conventions: * Use `ctx.auth.api.*` for multi-table operations (create, delete, invitations, member management) when they stay inside Convex. * Use `ctx.orm` for simple reads and updates * Use `authAction` from `convex/lib/crpc-action.ts` for Better Auth endpoints that can run external plugin work such as Stripe, Polar, or direct email delivery. Convex mutations cannot call those SDKs. * Check permissions with `hasPermission(ctx, { organizationId, permissions: { resource: ['action'] } })` * Use rate limiting via `.meta({ ratelimit: 'organization/action' })` See the [example app](https://github.com/nichochar/kitcn/tree/main/example) for complete CRUD implementations (update, delete, invitations, member management, leave with owner transfer, etc.). Teams [#teams] Teams allow grouping members within an organization. No dedicated team wrappers are defined in `example/convex/functions/organization.ts` yet. Use Better Auth team APIs directly where needed. Team Operations [#team-operations] ```ts showLineNumbers {2-5,8-11,14-17,20-23} // List teams const teams = await ctx.auth.api.listTeams({ query: { organizationId: ctx.user.activeOrganization!.id }, headers: ctx.auth.headers, }); // Add member to team await ctx.auth.api.addTeamMember({ body: { teamId, userId }, headers: ctx.auth.headers, }); // Remove member from team await ctx.auth.api.removeTeamMember({ body: { teamId, userId }, headers: ctx.auth.headers, }); // List team members const members = await ctx.auth.api.listTeamMembers({ body: { teamId }, headers: ctx.auth.headers, }); ``` Client Usage [#client-usage] React Hooks [#react-hooks] ```tsx title="src/components/org-switcher.tsx" showLineNumbers {1,4-8,11-13,16-18,21-29} import { authClient } from '@/lib/convex/auth-client'; function OrgSwitcher() { // Active organization (reactive) const { data: activeOrg } = authClient.useActiveOrganization(); // List organizations (reactive) const { data: orgs } = authClient.useListOrganizations(); // Set active organization const handleSwitch = (orgId: string) => { authClient.organization.setActive({ organizationId: orgId }); }; // Create organization const handleCreate = () => { authClient.organization.create({ name: 'New Org', slug: 'new-org' }); }; return ( ); } ``` Get Full Organization [#get-full-organization] ```tsx showLineNumbers {2-4,6} // Get full organization details (non-reactive) const { data: fullOrg } = await authClient.organization.getFullOrganization({ query: { organizationId: orgId }, }); // Returns: organization + members + invitations ``` Invitation Operations [#invitation-operations] ```tsx showLineNumbers {2,5-7,10-12,15-17} // Get user's pending invitations const { data: invitations } = await authClient.organization.listInvitations(); // Accept invitation await authClient.organization.acceptInvitation({ invitationId: 'inv_123', }); // Reject invitation await authClient.organization.rejectInvitation({ invitationId: 'inv_123', }); // Cancel invitation (as org admin) await authClient.organization.cancelInvitation({ invitationId: 'inv_123', }); ``` Member Operations [#member-operations] ```tsx showLineNumbers {2,5,8-10,13-16} // Get active member info const { data: member } = await authClient.organization.getActiveMember(); // Leave organization await authClient.organization.leave(); // Remove member (as admin) await authClient.organization.removeMember({ memberIdOrEmail: 'member_123', }); // Update member role await authClient.organization.updateMemberRole({ memberId: 'member_123', role: 'admin', }); ``` Permission Check [#permission-check] ```tsx showLineNumbers {2-4,6-8} // Check permission before showing UI const { data } = await authClient.organization.hasPermission({ permissions: { organization: ['delete'] }, }); if (data?.success) { // Show delete button } ``` Team Operations (Client) [#team-operations-client] ```tsx showLineNumbers {2,5-7,10-12} // List teams const { data: teams } = await authClient.organization.listTeams(); // Create team await authClient.organization.createTeam({ name: 'Engineering', }); // Set active team (stored in session) await authClient.organization.setActiveTeam({ teamId: 'team_123', }); ``` Hooks [#hooks] Hooks allow custom logic during organization lifecycle events. Organization Hooks [#organization-hooks] ```ts title="convex/functions/auth.ts" showLineNumbers {2-14} organization({ // Organization creation organizationCreation: { beforeCreate: async ({ organization, user }) => { // Validate or modify before creation return { data: organization }; }, afterCreate: async ({ organization, member, user }) => { // Setup default resources, create initial data }, }, // Organization deletion organizationDeletion: { beforeDelete: async (data) => { // Cleanup related resources before deletion }, afterDelete: async (data) => { // Post-deletion cleanup }, }, }), ``` Member Hooks [#member-hooks] ```ts title="convex/functions/auth.ts" showLineNumbers {2-20} organization({ membershipManagement: { beforeAddMember: async ({ organization, member, user }) => { // Validate member addition return { data: member }; }, afterAddMember: async ({ organization, member, user }) => { // Post-addition logic (notifications, etc.) }, beforeRemoveMember: async ({ organization, member, user }) => { // Cleanup before removal }, afterRemoveMember: async ({ organization, member, user }) => { // Post-removal logic }, beforeUpdateRole: async ({ organization, member, role }) => { return { data: { role } }; }, afterUpdateRole: async ({ organization, member, role }) => { // Role change notifications }, }, }), ``` Invitation Hooks [#invitation-hooks] ```ts title="convex/functions/auth.ts" showLineNumbers {2-15} organization({ invitationManagement: { beforeCreateInvitation: async ({ invitation, organization, inviter }) => { return { data: invitation }; }, afterCreateInvitation: async ({ invitation, organization, inviter }) => { // Send custom notification }, beforeAcceptInvitation: async ({ invitation, user }) => { return { data: invitation }; }, afterAcceptInvitation: async ({ invitation, member, user }) => { // Welcome logic }, }, }), ``` Team Hooks [#team-hooks] ```ts title="convex/functions/auth.ts" showLineNumbers {2-12} organization({ teamManagement: { beforeCreateTeam: async ({ team, organization }) => { return { data: team }; }, afterCreateTeam: async ({ team, organization }) => {}, beforeAddTeamMember: async ({ team, user }) => { return { data: { team, user } }; }, afterAddTeamMember: async ({ team, user }) => {}, }, }), ``` API Reference [#api-reference] | Operation | Method | Multi-table | | ------------------ | --------------- | ----------- | | Create org | Better Auth API | Yes | | Update org | ORM | No | | Delete org | Better Auth API | Yes | | List orgs | ORM | No | | Check slug | ORM | No | | Invite member | Better Auth API | Yes | | Accept invite | Better Auth API | Yes | | Reject invite | Better Auth API | Yes | | Cancel invite | Better Auth API | Yes | | List user invites | ORM | No | | Add member | Better Auth API | Yes | | Update role | ORM | No | | Remove member | Better Auth API | Yes | | Leave org | Better Auth API | Yes | | Create team | Better Auth API | Yes | | Add team member | Better Auth API | Yes | | Remove team member | Better Auth API | Yes | }> Use Better Auth API for operations that modify multiple tables. Use direct table operations for simple single-table reads/updates. Next Steps [#next-steps] # Polar (/docs/auth/plugins/polar) import { InfoIcon } from "lucide-react" Overview [#overview] Payment and subscription system with [Better Auth's Polar plugin](https://www.better-auth.com/docs/plugins/polar): | Feature | Description | | ------------- | ---------------------------- | | Checkout | Payment flows | | Subscriptions | Recurring billing management | | Portal | Customer self-service | | Usage billing | Event-based metering | | Webhooks | Real-time billing events | Prerequisites [#prerequisites] * [Auth Server](/docs/auth/server) configured * Polar account with products created at [Polar Dashboard](https://polar.sh/dashboard) * Environment variables configured 1. Install Dependencies [#1-install-dependencies] 2. Server Configuration [#2-server-configuration] Polyfills (REQUIRED) [#polyfills-required] Convex environment requires Buffer polyfill for Polar SDK: ```ts title="convex/lib/polar-polyfills.ts" showLineNumbers {1,3} import { Buffer as BufferPolyfill } from 'buffer'; globalThis.Buffer = BufferPolyfill; ``` Polar Client Helper [#polar-client-helper] ```ts title="convex/lib/polar-client.ts" showLineNumbers {1,3-8} import { Polar } from '@polar-sh/sdk'; export const getPolarClient = () => new Polar({ accessToken: process.env.POLAR_ACCESS_TOKEN!, server: process.env.POLAR_SERVER === 'production' ? 'production' : 'sandbox', }); ``` Better Auth with Polar Plugin [#better-auth-with-polar-plugin] ```ts title="convex/functions/auth.ts" showLineNumbers {2,7-8,10-53} // IMPORTANT: Import polyfills FIRST import '../lib/polar-polyfills'; import { checkout, polar, portal, usage, webhooks } from '@polar-sh/better-auth'; import { Polar } from '@polar-sh/sdk'; import { createPolarCustomerCaller } from './generated/polarCustomer.runtime'; import { createPolarSubscriptionCaller } from './generated/polarSubscription.runtime'; import { defineAuth } from './generated/auth'; export default defineAuth((ctx) => ({ // ... existing config plugins: [ polar({ client: new Polar({ accessToken: process.env.POLAR_ACCESS_TOKEN!, server: process.env.POLAR_SERVER === 'production' ? 'production' : 'sandbox', }), // Customer creation via scheduler (recommended for Convex) // createCustomerOnSignUp: true, // Use trigger instead use: [ checkout({ authenticatedUsersOnly: true, products: [ { productId: process.env.POLAR_PRODUCT_PREMIUM!, slug: 'premium' }, ], successUrl: `${process.env.SITE_URL}/success?checkout_id={CHECKOUT_ID}`, theme: 'light', }), portal(), usage(), webhooks({ secret: process.env.POLAR_WEBHOOK_SECRET!, onCustomerCreated: async (payload) => { const userId = payload?.data.externalId; if (!userId) return; const caller = createPolarCustomerCaller(ctx); await caller.updateUserPolarCustomerId({ customerId: payload.data.id, userId, }); }, onSubscriptionCreated: async (payload) => { if (!payload.data.customer.externalId) return; const caller = createPolarSubscriptionCaller(ctx); await caller.createSubscription({ subscription: convertToDatabaseSubscription(payload.data), }); }, onSubscriptionUpdated: async (payload) => { if (!payload.data.customer.externalId) return; const caller = createPolarSubscriptionCaller(ctx); await caller.updateSubscription({ subscription: convertToDatabaseSubscription(payload.data), }); }, }), ], }), ], })); ``` Customer Creation via Trigger [#customer-creation-via-trigger] Create Polar customer asynchronously when user signs up: ```ts title="convex/functions/auth.ts" showLineNumbers {1-2,7-12} import { defineAuth } from './generated/auth'; import { createPolarCustomerCaller } from './generated/polarCustomer.runtime'; export default defineAuth((ctx) => ({ // ... config triggers: { user: { create: { after: async (user, triggerCtx) => { // Create Polar customer via caller from defineAuth closure ctx const caller = createPolarCustomerCaller(ctx); await caller.schedule.now.createCustomer({ email: user.email, name: user.name || user.username, userId: user.id, }); }, }, }, }, })); ``` 3. Client Configuration [#3-client-configuration] ```ts title="src/lib/convex/auth-client.ts" showLineNumbers {2,6} import { polarClient } from '@polar-sh/better-auth'; import { createAuthClient } from 'better-auth/react'; export const authClient = createAuthClient({ plugins: [polarClient()], }); ``` 4. Schema [#4-schema] ```ts title="convex/functions/schema.ts" showLineNumbers {1-2,4-37} import { boolean, convexTable, defineSchema, id, index, integer, json, text } from 'kitcn/orm'; // User table - add Polar customer ID export const user = convexTable( 'user', { // ... existing fields customerId: text(), // Polar customer ID }, (t) => [index('customerId').on(t.customerId)] ); // Subscriptions table - organization-based export const subscriptions = convexTable( 'subscriptions', { subscriptionId: text().notNull(), organizationId: text().notNull(), userId: id('user').notNull(), productId: text().notNull(), priceId: text(), status: text().notNull(), // 'active', 'canceled', 'trialing', 'past_due' amount: integer(), currency: text(), recurringInterval: text(), currentPeriodStart: text().notNull(), currentPeriodEnd: text(), cancelAtPeriodEnd: boolean().notNull(), startedAt: text(), endedAt: text(), createdAt: text().notNull(), modifiedAt: text(), checkoutId: text(), metadata: json>(), customerCancellationReason: text(), customerCancellationComment: text(), }, (t) => [ index('subscriptionId').on(t.subscriptionId), index('organizationId').on(t.organizationId), index('organizationId_status').on(t.organizationId, t.status), index('userId_status').on(t.userId, t.status), ] ); export const tables = { user, subscriptions }; export default defineSchema(tables, { strict: false }); ``` 5. Helper Functions [#5-helper-functions] Subscription Conversion Helper [#subscription-conversion-helper] ```ts title="convex/lib/polar-helpers.ts" showLineNumbers {1-3,5-13,15-36} import type { Subscription } from '@polar-sh/sdk/models/components/subscription'; import type { WithoutSystemFields } from 'convex/server'; import type { Doc, Id } from '../functions/_generated/dataModel'; export const convertToDatabaseSubscription = ( subscription: Subscription ): WithoutSystemFields> => { // Extract organizationId from subscription metadata (referenceId) const organizationId = subscription.metadata ?.referenceId as Id<'organization'>; if (!organizationId) { throw new Error( 'Subscription missing organizationId in metadata.referenceId' ); } return { amount: subscription.amount, cancelAtPeriodEnd: subscription.cancelAtPeriodEnd, checkoutId: subscription.checkoutId, createdAt: subscription.createdAt.toISOString(), currency: subscription.currency, currentPeriodEnd: subscription.currentPeriodEnd?.toISOString() ?? null, currentPeriodStart: subscription.currentPeriodStart.toISOString(), customerCancellationComment: subscription.customerCancellationComment, customerCancellationReason: subscription.customerCancellationReason, endedAt: subscription.endedAt?.toISOString() ?? null, metadata: subscription.metadata ?? {}, modifiedAt: subscription.modifiedAt?.toISOString() ?? null, organizationId, productId: subscription.productId, recurringInterval: subscription.recurringInterval, startedAt: subscription.startedAt?.toISOString() ?? null, status: subscription.status, subscriptionId: subscription.id, // IMPORTANT: Use externalId, not metadata.userId userId: subscription.customer.externalId as Id<'user'>, }; }; ``` Checkout Plugin [#checkout-plugin] Configure checkout: ```ts title="convex/functions/auth.ts" showLineNumbers {1-9} checkout({ products: [ { productId: 'uuid-from-polar', slug: 'pro' }, { productId: 'uuid-from-polar', slug: 'enterprise' }, ], successUrl: `${process.env.SITE_URL}/success?checkout_id={CHECKOUT_ID}`, returnUrl: `${process.env.SITE_URL}`, // Optional back button authenticatedUsersOnly: true, theme: 'light', // or 'dark' }), ``` Client Checkout [#client-checkout] ```tsx showLineNumbers {2-5,8-11} // Using product slug await authClient.checkout({ slug: 'pro', referenceId: organizationId, // Links subscription to organization }); // Using product ID directly await authClient.checkout({ products: ['e651f46d-ac20-4f26-b769-ad088b123df2'], referenceId: organizationId, }); ``` Organization-Based Checkout [#organization-based-checkout] ```tsx title="src/components/checkout-button.tsx" showLineNumbers {1-6,9-20} const handleSubscribe = async () => { const activeOrganizationId = user.activeOrganization?.id; if (!activeOrganizationId) { toast.error('Please select an organization'); return; } try { if (currentUser.plan) { // User has plan - open portal to manage await authClient.customer.portal(); } else { // No plan - initiate checkout await authClient.checkout({ slug: 'premium', referenceId: activeOrganizationId, }); } } catch (error) { console.error('Polar checkout error:', error); toast.error('Failed to open checkout'); } }; ``` Portal Plugin [#portal-plugin] Customer self-service portal for managing subscriptions: Open Portal [#open-portal] ```tsx showLineNumbers await authClient.customer.portal(); ``` Customer State [#customer-state] ```tsx showLineNumbers {1,3-7} const { data: customerState } = await authClient.customer.state(); // Contains: // - Customer data // - Active subscriptions // - Granted benefits // - Active meters with balance ``` List Benefits [#list-benefits] ```tsx showLineNumbers {1-3} const { data: benefits } = await authClient.customer.benefits.list({ query: { page: 1, limit: 10 }, }); ``` List Orders [#list-orders] ```tsx showLineNumbers {1-5} const { data: orders } = await authClient.customer.orders.list({ query: { page: 1, limit: 10, productBillingType: 'one_time', // or 'recurring' }, }); ``` List Subscriptions [#list-subscriptions] ```tsx showLineNumbers {1-6} const { data: subscriptions } = await authClient.customer.subscriptions.list({ query: { page: 1, limit: 10, active: true, }, }); ``` Organization Subscriptions [#organization-subscriptions] ```tsx showLineNumbers {1,3-10,12-14} const organizationId = (await authClient.organization.list())?.data?.[0]?.id; const { data: subscriptions } = await authClient.customer.orders.list({ query: { page: 1, limit: 10, active: true, referenceId: organizationId, }, }); const userShouldHaveAccess = subscriptions.some( (sub) => sub.productId === process.env.NEXT_PUBLIC_POLAR_PRODUCT_PREMIUM ); ``` Usage Plugin [#usage-plugin] Usage-based billing with event ingestion: Event Ingestion [#event-ingestion] ```tsx showLineNumbers {1-6} const { data: ingested } = await authClient.usage.ingestion({ event: 'file-uploads', metadata: { uploadedFiles: 12, totalSizeBytes: 1024000, }, }); ``` Customer Meters [#customer-meters] ```tsx showLineNumbers {1-3,5-8} const { data: customerMeters } = await authClient.usage.meters.list({ query: { page: 1, limit: 10 }, }); // Returns: // - Customer information // - Meter configuration // - Consumed units, credited units, balance ``` Webhooks Plugin [#webhooks-plugin] The `webhooks()` plugin verifies Polar webhook signatures and routes events to typed handler callbacks. Pass it your `POLAR_WEBHOOK_SECRET` and implement any of the \~20 available event handlers. For the full list of webhook events, see [API Reference](#webhook-events) below. Convex Functions [#convex-functions] }> Function examples use the ORM (`ctx.orm`). Customer Management [#customer-management] ```ts title="convex/functions/polarCustomer.ts" showLineNumbers {1,3-8,11-18,20-31} import '../lib/polar-polyfills'; import { CRPCError } from 'kitcn/server'; import * as z from 'zod'; import { privateAction, privateMutation } from '../lib/crpc'; import { getPolarClient } from '../lib/polar-client'; // Create Polar customer (called from user.create.after trigger) export const createCustomer = privateAction .input( z.object({ email: z.string().email(), name: z.string().optional(), userId: z.string(), }) ) .action(async ({ input: args }) => { const polar = getPolarClient(); try { await polar.customers.create({ email: args.email, externalId: args.userId, // Links Polar customer to Convex user name: args.name, }); } catch (error) { console.error('Failed to create Polar customer:', error); } return null; }); ``` Build additional subscription management functions (create, update, cancel, resume) following this pattern. The key conventions: * Use `privateMutation` for webhook-triggered writes (subscription create/update) * Use `privateQuery` for internal reads (get active subscription) * Use `authAction` from `convex/lib/crpc-action.ts` for user-facing operations that call the Polar SDK (cancel/resume) * Use callers to bridge between actions and queries/mutations See the [example app](https://github.com/nichochar/kitcn/tree/main/example) for complete subscription CRUD implementations. Customer Deletion Sync [#customer-deletion-sync] Sync user deletion with Polar: ```ts title="convex/functions/auth.ts" showLineNumbers {1,4-14} import { defineAuth } from './generated/auth'; export default defineAuth((ctx) => ({ user: { deleteUser: { enabled: true, afterDelete: async (user) => { const polar = getPolarClient(); await polar.customers.deleteExternal({ externalId: user.id, }); }, }, }, })); ``` Environment Variables [#environment-variables] ```bash title="convex/.env" showLineNumbers {2-4,7} # Polar API Configuration POLAR_SERVER="sandbox" # 'production' | 'sandbox' POLAR_ACCESS_TOKEN="polar_at_..." # Organization access token POLAR_WEBHOOK_SECRET="whsec_..." # Webhook signature secret # Polar Product IDs (from Polar dashboard) POLAR_PRODUCT_PREMIUM="uuid-here" # Premium subscription product ``` Local Development with Ngrok [#local-development-with-ngrok] Polar webhooks require a public URL. Use ngrok to expose your local server: Setup [#setup] 1. Install ngrok: [macOS setup guide](https://dashboard.ngrok.com/get-started/setup/macos) 2. Reserve a free static domain in the [ngrok dashboard](https://dashboard.ngrok.com/domains) 3. Add scripts to `package.json`: ```json title="package.json" showLineNumbers {3-4} { "scripts": { "dev": "concurrently 'next dev' 'bun ngrok'", "ngrok": "ngrok http --url=your-domain.ngrok-free.app 3000" } } ``` 4. Configure the webhook URL in [Polar Dashboard](https://polar.sh/dashboard) → Settings → Webhooks: ``` https://your-domain.ngrok-free.app/api/auth/polar/webhooks ``` Common Patterns [#common-patterns] Check Organization Subscription [#check-organization-subscription] ```ts showLineNumbers {1-5,7} const subscription = await ctx.orm.query.subscriptions.findFirst({ where: { organizationId, status: 'active' }, }); const isActive = subscription?.status === 'active'; ``` Check User Subscription [#check-user-subscription] ```ts showLineNumbers {1-5,7} const subscription = await ctx.orm.query.subscriptions.findFirst({ where: { userId, status: 'active' }, }); const isPremium = !!subscription; ``` API Reference [#api-reference] Webhook Events [#webhook-events] All events are passed to `webhooks({ secret, ...handlers })` inside the `use: []` array of the `polar()` plugin. ```ts showLineNumbers title="convex/functions/auth.ts" webhooks({ secret: process.env.POLAR_WEBHOOK_SECRET!, // Checkout onCheckoutCreated: (payload) => {}, onCheckoutUpdated: (payload) => {}, // Orders onOrderCreated: (payload) => {}, onOrderPaid: (payload) => {}, onOrderRefunded: (payload) => {}, // Refunds onRefundCreated: (payload) => {}, onRefundUpdated: (payload) => {}, // Subscriptions onSubscriptionCreated: (payload) => {}, onSubscriptionUpdated: (payload) => {}, onSubscriptionActive: (payload) => {}, onSubscriptionCanceled: (payload) => {}, onSubscriptionRevoked: (payload) => {}, onSubscriptionUncanceled: (payload) => {}, // Products onProductCreated: (payload) => {}, onProductUpdated: (payload) => {}, // Benefits onBenefitCreated: (payload) => {}, onBenefitUpdated: (payload) => {}, onBenefitGrantCreated: (payload) => {}, onBenefitGrantUpdated: (payload) => {}, onBenefitGrantRevoked: (payload) => {}, // Customers onCustomerCreated: (payload) => {}, onCustomerUpdated: (payload) => {}, onCustomerDeleted: (payload) => {}, onCustomerStateChanged: (payload) => {}, // Catch-all onPayload: (payload) => {}, }), ``` Operations [#operations] | Operation | Method | Type | | ------------------- | -------------------------------------------------- | ----------------- | | Checkout | `authClient.checkout` | Client | | Customer portal | `authClient.customer.portal` | Client | | Customer state | `authClient.customer.state` | Client | | List benefits | `authClient.customer.benefits.list` | Client | | List orders | `authClient.customer.orders.list` | Client | | List subscriptions | `authClient.customer.subscriptions.list` | Client | | Event ingestion | `authClient.usage.ingestion` | Client | | List meters | `authClient.usage.meters.list` | Client | | Create customer | `internal.polarCustomer.createCustomer` | Internal action | | Link customer ID | `internal.polarCustomer.updateUserPolarCustomerId` | Internal mutation | | Create subscription | `internal.polarSubscription.createSubscription` | Internal mutation | | Update subscription | `internal.polarSubscription.updateSubscription` | Internal mutation | | Cancel subscription | Convex action | User action | | Resume subscription | Convex action | User action | }> Use Better Auth client API for checkout and portal operations. Use internal Convex functions for webhook handlers. Use user actions for subscription management. Next Steps [#next-steps] # Delete (/docs/orm/mutations/delete) import { InfoIcon, AlertTriangle } from "lucide-react" Delete Rows [#delete-rows] ```ts showLineNumbers title="convex/functions/users.ts" import * as z from 'zod'; import { eq } from 'kitcn/orm'; import { publicMutation } from '../lib/crpc'; import { users } from '../schema'; export const removeUser = publicMutation .input(z.object({ userId: z.string() })) .mutation(async ({ ctx, input }) => { await ctx.orm.delete(users).where(eq(users.id, input.userId)); }); ``` To delete all rows in a table (use with care): ```ts showLineNumbers await ctx.orm.delete(users).allowFullScan(); ``` }> **Important:** `delete()` without `.where(...)` throws unless you call `.allowFullScan()`. See [Querying Data](/docs/orm/queries) for details on `allowFullScan`. Returning [#returning] Use `.returning()` to get back the deleted rows. You can return all fields or pick specific columns: ```ts showLineNumbers const deleted = await ctx.orm .delete(users) .where(eq(users.id, userId)) .returning(); const ids = await ctx.orm .delete(users) .where(eq(users.id, userId)) .returning({ id: users.id }); ``` The ORM collects matching rows in bounded pages before applying writes. See [Mutations](/docs/orm/mutations) for safety limit defaults and override syntax. Paginated Delete Execution [#paginated-delete-execution] For large workloads, you can process delete batches page-by-page with `.paginate()`. This requires an index on the filtered field: ```ts showLineNumbers // Schema: index('by_role').on(t.role) on users table ``` ```ts showLineNumbers {5} const page1 = await ctx.orm .delete(users) .where(eq(users.role, 'inactive')) .paginate({ cursor: null, limit: 100 }); if (!page1.isDone) { const page2 = await ctx.orm .delete(users) .where(eq(users.role, 'inactive')) .paginate({ cursor: page1.continueCursor, limit: 100 }); } ``` Each page returns: * `continueCursor` -- cursor for the next batch * `isDone` -- `true` when no more pages remain * `numAffected` -- rows deleted in this page * `page` -- returned rows (only when `.returning()` is used) }> **Note:** `paginate()` currently supports single-range index plans. Multi-probe filters (`inArray`, some `OR` patterns, complement ranges) are not yet supported in paged mutation mode. Async Batched Delete [#async-batched-delete] Deletes run in **async mode by default**. The first batch runs in the current mutation, then remaining batches are scheduled automatically. Customize batch size and delay per call: ```ts showLineNumbers {5} const firstBatch = await ctx.orm .delete(users) .where(eq(users.role, 'inactive')) .returning({ id: users.id }) .execute({ batchSize: 200, delayMs: 0 }); ``` See [API Reference -- Async Batched Delete Behaviors](#async-batched-delete-behaviors) for resolution precedence details. To force sync execution (all rows in a single transaction), use `.execute({ mode: 'sync' })` or set `defineSchema(..., { defaults: { mutationExecutionMode: 'sync' } })`. Table Default Delete Mode [#table-default-delete-mode] You can configure default delete semantics per table using `deletion(...)` in the table definition. This is useful when a table should always default to soft or scheduled deletes. ```ts showLineNumbers title="convex/schema.ts" {8} import { convexTable, deletion, integer, text } from 'kitcn/orm'; const users = convexTable( 'users', { slug: text().notNull(), deletionTime: integer(), }, () => [deletion('scheduled', { delayMs: 60_000 })] ); ``` Delete mode precedence (highest to lowest): 1. Per-query override (`.hard()`, `.soft()`, `.scheduled({ delayMs })`) 2. Table default via `deletion(...)` 3. Fallback: `'hard'` When a table defaults to scheduled mode, use `.hard()` to opt out for a specific delete: ```ts showLineNumbers await ctx.orm.delete(users).where(eq(users.id, userId)).hard(); ``` Scheduled Delete Cancellation [#scheduled-delete-cancellation] `.scheduled({ delayMs })` stores the current `deletionTime` and passes it to the scheduled worker. The worker only proceeds if the row still has the same `deletionTime`. To cancel a pending scheduled hard-delete, clear or change `deletionTime` before the worker runs. Drizzle Differences [#drizzle-differences] A few SQL-only features from Drizzle are not applicable in Convex: * `limit`, `orderBy`, and `WITH` clauses are not supported * Deletes are executed against Convex documents; there is no SQL `DELETE FROM ...` }> **Note:** Foreign key actions and RLS policies are enforced at runtime for ORM deletes. Direct `ctx.db.delete(...)` bypasses these checks. API Reference [#api-reference] Async Batched Delete Behaviors [#async-batched-delete-behaviors] * With `.returning()`, you get rows from the first batch only — remaining batches are scheduled * Async mode cannot be combined with `.paginate()` or resolved scheduled delete mode (`.scheduled(...)` or table-level `deletion('scheduled')`) * `batchSize` resolves as: per-call `batchSize` > `defaults.mutationBatchSize` > `400` * `delayMs` resolves as: per-call `delayMs` > `defaults.mutationAsyncDelayMs` > `0` * FK/cascade async fan-out uses `mutationBatchSize` for recursive delete cascade (`onDelete: 'cascade'`) and `mutationLeafBatchSize` for non-recursive actions (`set null`, `set default`) Next Steps [#next-steps] Shared mutation concepts: returning, where, atomicity, and async wiring Insert rows, multi-row inserts, and upserts Update rows with set/where/returning Configure delete modes and runtime defaults # Mutations (/docs/orm/mutations) import { InfoIcon, CheckCircle } from "lucide-react" Dedicated Pages [#dedicated-pages] Each mutation type has its own detailed page: Insert rows, returning, and onConflict upserts Update rows with set/where/returning Delete rows with where/returning Setup [#setup] All mutation examples assume you attached ORM to `ctx.orm` once in your context (see [Quickstart](/docs/quickstart#orm-setup)). Typical import block for mutation handlers: ```ts showLineNumbers title="convex/functions/users.ts" import * as z from 'zod'; import { eq } from 'kitcn/orm'; import { publicMutation } from '../lib/crpc'; import { users } from '../schema'; ``` Shared Concepts [#shared-concepts] Concepts that apply across `insert`, `update`, and `delete`. Returning [#returning] Every mutation builder supports `.returning()` to get back the affected rows. Without it, the result is `void`. You can return all fields or pick specific columns: ```ts showLineNumbers // Return all fields const [user] = await ctx.orm .insert(users) .values({ name: 'Ada', email: 'ada@example.com' }) .returning(); // Return specific fields const [partial] = await ctx.orm .insert(users) .values({ name: 'Ada', email: 'ada@example.com' }) .returning({ id: users.id, email: users.email }); ``` }> **Note:** `.returning()` always returns an array, even for single-row mutations. Where Clauses [#where-clauses] `update()` and `delete()` require a `.where(...)` clause by default. Calling without one throws an error unless you explicitly opt in with `.allowFullScan()`. ```ts showLineNumbers // Target specific rows with where await ctx.orm.update(users).set({ name: 'Updated' }).where(eq(users.id, userId)); // Operate on ALL rows (use with care) await ctx.orm.delete(users).allowFullScan(); ``` For more on `allowFullScan` and index compilation, see [Querying Data](/docs/orm/queries). Atomicity [#atomicity] }> **Important:** Convex mutations are atomic -- all changes in a single mutation call succeed or fail together. The ORM enforces runtime limits on how many rows a single mutation can touch. See [API Reference -- Safety Limits](#safety-limits) for defaults and override syntax. Paginated Mutation Batches [#paginated-mutation-batches] For large update/delete workloads that exceed safety limits, you can process rows page-by-page with `.paginate()`. This requires an index on the filtered field: ```ts showLineNumbers // Schema: index('by_role').on(t.role) on users table ``` ```ts showLineNumbers {5} const batch = await ctx.orm .update(users) .set({ role: 'member' }) .where(eq(users.role, 'pending')) .paginate({ cursor: null, limit: 100 }); ``` The paginated result includes: * `continueCursor` -- cursor for the next batch * `isDone` -- `true` when no more pages remain * `numAffected` -- rows affected in this page * `page` -- returned rows (only when `.returning()` is used) Async Mutation Batching [#async-mutation-batching] Mutations that affect large sets of rows run in **async mode by default**. The first batch runs in the current mutation, then remaining batches are scheduled automatically through the Convex scheduler. ```ts showLineNumbers title="convex/functions/users.ts" {7} import { eq } from 'kitcn/orm'; import { publicMutation } from '../lib/crpc'; import { users } from '../schema'; export const renameUsers = publicMutation.mutation(async ({ ctx }) => { await ctx.orm .update(users) .set({ name: 'Updated' }) .where(eq(users.role, 'pending')); // async by default }); ``` You can customize batch size and delay per call: ```ts showLineNumbers await ctx.orm .update(users) .set({ name: 'Updated' }) .where(eq(users.role, 'pending')) .execute({ batchSize: 200, delayMs: 0 }); ``` Sync Mode [#sync-mode] To force all rows to be processed in a single transaction (no scheduling), opt into sync mode: * **Per call:** `.execute({ mode: 'sync' })` * **Global default:** `defineSchema(..., { defaults: { mutationExecutionMode: 'sync' } })` Sync mode throws if matched rows exceed `mutationMaxRows`. API Reference [#api-reference] Safety Limits [#safety-limits] The ORM enforces runtime limits on how many rows a single mutation can touch. The key defaults are: * `mutationBatchSize`: `400` (page size for collecting matched rows) * `mutationMaxRows`: `10000` (sync-mode hard cap) * `mutationLeafBatchSize`: `1600` (async FK fan-out batch size) You can customize these in your schema definition. For the full list of configurable defaults, see [Schema Definition -- Runtime Defaults](/docs/orm/schema#runtime-defaults). Next Steps [#next-steps] Multi-row inserts, upserts, and conflict handling Paginated updates, async batching, and safety limits Soft deletes, scheduled deletes, and cascades Configure runtime defaults and table-level settings # Insert (/docs/orm/mutations/insert) import { InfoIcon, AlertTriangle } from "lucide-react" Basic Insert [#basic-insert] ```ts showLineNumbers title="convex/functions/users.ts" import * as z from 'zod'; import { publicMutation } from '../lib/crpc'; import { users } from '../schema'; export const createUser = publicMutation .input(z.object({ email: z.string().email(), name: z.string() })) .mutation(async ({ ctx, input }) => { await ctx.orm.insert(users).values({ email: input.email, name: input.name, }); }); ``` If you need the insert type for a table, you can infer it from the table definition: ```ts showLineNumbers type NewUser = typeof users.$inferInsert; ``` Returning [#returning] By default, `insert()` returns `void`. Use `.returning()` to get the inserted rows back. You can return all fields or pick specific columns: ```ts showLineNumbers {3,8} const [user] = await ctx.orm .insert(users) .values({ email: 'ada@example.com', name: 'Ada' }) .returning(); const [partial] = await ctx.orm .insert(users) .values({ email: 'grace@example.com', name: 'Grace' }) .returning({ id: users.id, email: users.email }); ``` }> **Note:** `.returning()` always returns an array, even for single-row inserts. Insert Multiple Rows [#insert-multiple-rows] You can pass an array to `.values()` to insert several rows at once: ```ts showLineNumbers await ctx.orm.insert(users).values([ { email: 'a@example.com', name: 'A' }, { email: 'b@example.com', name: 'B' }, ]); ``` Upserts and Conflicts [#upserts-and-conflicts] The ORM mirrors Drizzle's `onConflictDoNothing()` and `onConflictDoUpdate()` surface, enforced at runtime against your table's unique constraints and indexes. On Conflict Do Nothing [#on-conflict-do-nothing] Use `onConflictDoNothing` to silently skip rows that would violate a unique constraint: ```ts showLineNumbers {4} await ctx.orm .insert(users) .values({ email: 'ada@example.com', name: 'Ada' }) .onConflictDoNothing({ target: users.email }); ``` You can also omit `target` to skip inserts on **any** unique conflict: ```ts showLineNumbers {4} await ctx.orm .insert(users) .values({ email: 'ada@example.com', name: 'Ada' }) .onConflictDoNothing(); ``` On Conflict Do Update [#on-conflict-do-update] Use `onConflictDoUpdate` to update the existing row when a conflict occurs: ```ts showLineNumbers {4-7} await ctx.orm .insert(users) .values({ email: 'ada@example.com', name: 'Ada' }) .onConflictDoUpdate({ target: users.email, set: { name: 'Ada Lovelace' }, }); ``` }> **Important:** `onConflictDoUpdate()` requires a `target` column and a backing unique constraint or index (`uniqueIndex()`, `.unique()`, `unique()`). Constraint and RLS Enforcement [#constraint-and-rls-enforcement] The ORM enforces unique constraints, foreign keys, and RLS policies at runtime for all insert and upsert operations. }> **Note:** Direct `ctx.db.insert(...)` bypasses these checks. Use `ctx.orm` to get full constraint enforcement. Next Steps [#next-steps] Shared mutation concepts: returning, where, atomicity, and async batching Update rows with set/where/returning Delete rows with where/returning Define tables, constraints, and runtime defaults # Update (/docs/orm/mutations/update) import { InfoIcon, AlertTriangle } from "lucide-react" Basic Update [#basic-update] ```ts showLineNumbers title="convex/functions/users.ts" import * as z from 'zod'; import { eq } from 'kitcn/orm'; import { publicMutation } from '../lib/crpc'; import { users } from '../schema'; export const renameUser = publicMutation .input(z.object({ userId: z.string(), name: z.string() })) .mutation(async ({ ctx, input }) => { await ctx.orm .update(users) .set({ name: input.name }) .where(eq(users.id, input.userId)); }); ``` }> **Important:** `update()` without `.where(...)` throws unless you call `.allowFullScan()`. See [Querying Data](/docs/orm/queries) for details on `allowFullScan`. Returning [#returning] Use `.returning()` to get back the updated rows. You can return all fields or pick specific columns: ```ts showLineNumbers const updated = await ctx.orm .update(users) .set({ name: 'Mr. Dan' }) .where(eq(users.id, userId)) .returning(); const ids = await ctx.orm .update(users) .set({ name: 'Mr. Dan' }) .where(eq(users.id, userId)) .returning({ id: users.id }); ``` The ORM collects matching rows in bounded pages before applying writes. See [Mutations](/docs/orm/mutations) for safety limit defaults and override syntax. Paginated Update Execution [#paginated-update-execution] For large workloads that exceed safety limits, you can process updates page-by-page. This follows Convex's batching pattern and avoids one large transaction. Process updates across multiple pages (requires an index on the filtered field): ```ts showLineNumbers // Schema: index('by_role').on(t.role) on users table ``` ```ts showLineNumbers {5} const page1 = await ctx.orm .update(users) .set({ role: 'member' }) .where(eq(users.role, 'pending')) .paginate({ cursor: null, limit: 100 }); if (!page1.isDone) { const page2 = await ctx.orm .update(users) .set({ role: 'member' }) .where(eq(users.role, 'pending')) .paginate({ cursor: page1.continueCursor, limit: 100 }); } ``` Each page returns: * `continueCursor` -- cursor for the next batch * `isDone` -- `true` when no more pages remain * `numAffected` -- rows updated in this page * `page` -- returned rows (only when `.returning()` is used) }> **Note:** `paginate()` currently supports single-range index plans. Multi-probe filters (`inArray`, some `OR` patterns, complement ranges) are not yet supported in paged mutation mode. Async Batched Update [#async-batched-update] Updates run in **async mode by default**. The first batch runs in the current mutation, then remaining batches are scheduled automatically. Customize batch size and delay per call: ```ts showLineNumbers {5} const firstBatch = await ctx.orm .update(users) .set({ role: 'member' }) .where(eq(users.role, 'pending')) .returning({ id: users.id }) .execute({ batchSize: 200, delayMs: 0 }); ``` See [API Reference -- Async Batched Update Behaviors](#async-batched-update-behaviors) for resolution precedence details. To force sync execution (all rows in a single transaction), use `.execute({ mode: 'sync' })` or set `defineSchema(..., { defaults: { mutationExecutionMode: 'sync' } })`. Drizzle Differences [#drizzle-differences] A few SQL-only features from Drizzle are not applicable in Convex: * `limit`, `orderBy`, `UPDATE ... FROM`, and `WITH` clauses are not supported * `undefined` values passed to `.set(...)` are ignored (treated as "not provided"). If everything is `undefined`, the update is a no-op. * to explicitly remove a field, use `unsetToken`: `.set({ nickname: unsetToken })` (shallow: unsets the top-level field only) }> **Note:** Unique constraints, foreign keys, and RLS policies are enforced at runtime for ORM mutations. Direct native Convex writes like `ctx.db.patch(...)` bypass these checks (and are intentionally not exposed on `ctx.orm`). API Reference [#api-reference] Async Batched Update Behaviors [#async-batched-update-behaviors] * With `.returning()`, you get rows from the first batch only — remaining batches are scheduled * Async mode cannot be combined with `.paginate()` * `batchSize` resolves as: per-call `batchSize` > `defaults.mutationBatchSize` > `400` * `delayMs` resolves as: per-call `delayMs` > `defaults.mutationAsyncDelayMs` > `0` * Async FK update fan-out (`onUpdate: 'cascade'`, `set null`, `set default`) uses `mutationLeafBatchSize` Next Steps [#next-steps] Shared mutation concepts: returning, where, atomicity, and async wiring Insert rows, multi-row inserts, and upserts Delete rows with soft/scheduled/hard modes Configure runtime defaults and safety limits # Aggregates (/docs/orm/queries/aggregates) import { InfoIcon } from "lucide-react" Scalar Metrics [#scalar-metrics] }> **Setup:** Aggregate and rank indexes are an optional ORM subsystem. Register it once where you create the ORM: ```ts title="convex/functions/generated/server.ts" import { createOrm } from 'kitcn/orm'; import { aggregateCapability } from 'kitcn/orm/aggregate-index'; export const orm = createOrm({ schema, capabilities: [aggregateCapability()] }); ``` `kitcn codegen` writes this for schemas that declare an `aggregateIndex` or `rankIndex`, and leaves it out otherwise. Convex bundles everything a module statically imports and has no dynamic `import()`, and every procedure module imports `generated/server.ts`, so an unused registration would put the aggregate btree in every Convex function of the app. Declare the index first, then rerun `kitcn codegen`. Without the capability, `aggregateIndex`/`rankIndex` schemas and filtered `count()` / `aggregate()` / `groupBy()` / `rank()` / relation `_count` throw a setup error. Unfiltered `count()` and `aggregate({ _count: true })` are served by the native Convex count syscall and need nothing. The easiest way to aggregate. Declare an `aggregateIndex` on your table, and the ORM handles everything — no triggers, no manual maintenance. }> **Important:** Scalar metrics are strict no-scan. Filtered aggregate/count paths require a declared `aggregateIndex` matching the filter shape. Missing index → `COUNT_NOT_INDEXED` or `AGGREGATE_NOT_INDEXED` error. Unfiltered `count()` uses the native Convex count syscall. Declaring Aggregate Indexes [#declaring-aggregate-indexes] Add `aggregateIndex` to your table definition: ```ts title="convex/functions/schema.ts" showLineNumbers {3} const users = convexTable('users', { orgId: text().notNull(), status: text(), email: text() }, (t) => [ aggregateIndex('by_org_status').on(t.orgId, t.status), ]); ``` `.on(fields)` defines the filter key — you'll query filtered counts/aggregates by these fields. For unfiltered aggregate blocks (like `_sum/_avg/_min/_max`), use `.all()`. Plain `count()` without `where` uses the native syscall. You can chain metric declarations: ```ts title="convex/functions/schema.ts" showLineNumbers {10-17} const orders = convexTable( 'orders', { orgId: text().notNull(), status: text(), customerEmail: text(), amount: integer(), score: integer(), }, (t) => [ aggregateIndex('all_metrics') .all() .count(t.customerEmail) .sum(t.amount) .avg(t.amount) .min(t.score) .max(t.score), aggregateIndex('by_org_status') .on(t.orgId, t.status) .count(t.customerEmail) .sum(t.amount) .avg(t.amount) .min(t.score) .max(t.score), ] ); ``` After deploying, the CLI runs `aggregateBackfill` automatically. Wait for `aggregateBackfillStatus` (an internal query) to report `READY` before querying. Write costs [#write-costs] Bulk ORM statements reuse aggregate bucket and membership reads until the statement ends or a user hook or policy callback runs. Uninterrupted statements read each bucket once per distinct key tuple and each membership once per document. Callbacks and later statements reload stored rows so writes from nested mutations stay visible. Raw writer calls do not share these cached rows. Writes to shared aggregate buckets and extrema entries are folded within the statement. Aggregate reads, including returning relation counts, flush pending writes first. User hooks and policy callbacks also flush before they run, and batching stays suspended until they settle so nested functions see stored writes. More reads or callbacks can mean more flushes. Membership rows remain one write per changed document. count() [#count] Count rows efficiently: ```ts showLineNumbers {1-2,4-6,8-13} // Total users (native count syscall; no aggregateIndex required) const totalUsers = await ctx.orm.query.users.count(); // Filtered count (requires aggregateIndex matching filter shape) const activeUsers = await ctx.orm.query.users.count({ where: { status: 'active' }, }); // Per-field non-null counts const nonNulls = await ctx.orm.query.users.count({ where: { status: 'active' }, select: { _all: true, email: true, }, }); ``` Filtered `count()` accepts index-compilable filters: `eq`, `in`, `isNull`, `gt`, `gte`, `lt`, `lte`, conjunction via `AND`, and bounded finite DNF `OR` when every branch is index-plannable on the same `aggregateIndex`. Field counts in `select` require `.count(field)` metric declarations on the matching `aggregateIndex`. `isNull: true` matches a column that is explicitly `null` and a column that is absent from the document — a nullable column is optional, so a row inserted without it stores it absent. Matching `null` exactly (`{ status: null }` or `{ status: { eq: null } }`) matches only the explicitly-`null` rows. `count()`, `aggregate()`, `groupBy()`, and relation `_count` use the same null semantics as `findMany()`. Missing or mismatched indexes throw deterministic errors — see [Error Codes](#error-codes) below. For windowed counts with `orderBy`, `skip`, `take`, and `cursor`, see [Windowed Count](#windowed-count) below. aggregate() [#aggregate] For sum, avg, min, and max — use Prisma-style aggregate blocks: ```ts showLineNumbers {1-2,4-5,7-8,10-11,13-14} const metrics = await ctx.orm.query.orders.aggregate({ where: { orgId: 'org-1', status: 'paid' }, _count: { _all: true, customerEmail: true, }, _sum: { amount: true, }, _avg: { amount: true, }, _min: { score: true, }, _max: { score: true, }, }); ``` Same filter rules as `count()` — strict no-scan, index-backed. Supports bounded finite DNF `OR` when every branch is index-plannable and resolves to the same `aggregateIndex`. Unsupported args (callback `where`, relation filters) throw `AGGREGATE_ARGS_UNSUPPORTED`. For windowed aggregate queries with `orderBy`, `cursor`, `skip`, and `take`, see [Windowed Aggregate](#windowed-aggregate) below. groupBy() [#groupby] For analytics-style queries, `groupBy()` lets you bucket rows and compute metrics per group. It follows the same strict no-scan guardrails: * `by` is required * every `by` field must have finite `where` constraints (`eq`/`in`/`isNull`) * an `isNull`-constrained `by` field emits one group keyed `null`, covering both explicitly-`null` and absent rows * `orderBy` supports `by` fields and selected metric fields * `skip`/`take`/`cursor` require explicit `orderBy` * `having` supports conjunction filters on `by` fields and selected metric outputs * `OR`/`NOT` in `having` are unsupported (`AGGREGATE_FILTER_UNSUPPORTED`) ```ts showLineNumbers const grouped = await ctx.orm.query.orders.groupBy({ by: ['orgId'], where: { orgId: { in: ['org-1', 'org-2'] }, status: 'paid', }, _count: true, _sum: { amount: true }, _avg: { amount: true }, orderBy: [{ _count: 'desc' }, { _sum: { amount: 'desc' } }], having: { _count: { gt: 0 } }, take: 10, }); ``` If any `by` field is unconstrained, `groupBy()` throws `AGGREGATE_ARGS_UNSUPPORTED` to avoid implicit bucket-wide scans. Relation _count [#relation-_count] You can load relation counts alongside query results — no N+1: ```ts showLineNumbers {2-3} const projects = await ctx.orm.query.projects.findMany({ with: { _count: { todos: true, members: true } }, }); // projects[0]._count?.todos => 5 ``` You can filter the counted relations: ```ts showLineNumbers {3-5} const users = await ctx.orm.query.users.findMany({ with: { _count: { todos: { where: { completed: true } }, }, }, }); // users[0]._count?.todos => 3 ``` Through-filtered `_count` also works for `through()` relations using indexed lookups: ```ts showLineNumbers {3-5} const users = await ctx.orm.query.users.findMany({ with: { _count: { memberTeams: { where: { name: 'Core' } }, }, }, }); // users[0]._count?.memberTeams => 1 ``` Access counts via `row._count?.relation ?? 0`. Works on `findMany`, `findFirst`, and `findFirstOrThrow`. `_count` reads an aggregate index instead of the rows, so it resolves at every level of a nested `with:` — including the deepest one, whose children were not loaded. Ask for it there to tell a caller how much a tree withheld, rather than re-reading the returned nodes to count their children: ```ts showLineNumbers {3,7} const comments = await ctx.orm.query.comments.findMany({ with: { _count: { replies: true }, replies: { limit: 10, with: { _count: { replies: true }, }, }, }, }); // comments[0].replies[0]._count?.replies => 4 replies not in this page ``` Mutation returning({ _count }) [#mutation-returning-_count-] You can also include `_count` in mutation returning selections — handy for returning fresh counts after a write: ```ts showLineNumbers {4-5} const [user] = await ctx.orm .insert(usersTable) .values({ name: 'Alice' }) .returning({ id: usersTable.id, _count: { posts: true }, }); // user._count?.posts => 0 ``` This works with filters too: ```ts showLineNumbers {5-6} const [updated] = await ctx.orm .update(usersTable) .set({ name: 'Bob' }) .where(eq(usersTable.id, userId)) .returning({ id: usersTable.id, _count: { posts: { where: { status: 'published' } } }, }); // updated._count?.posts => 2 ``` Works on `insert`, `update`, and `delete` via split selection + relation count loading. Ranked Access With rankIndex [#ranked-access-with-rankindex] For leaderboards, random picks, and ordered pagination, use `rankIndex` in schema and `rank()` in queries. }> `rank()` is strict no-scan. You must query a declared `rankIndex`, and required partition fields must be constrained in `where`. | Operation | Description | | ------------------------------------ | --------------------------- | | `rank().indexOf({ id })` | Position/rank of a document | | `rank().at(offset)` | Row at a specific position | | `rank().paginate({ cursor, limit })` | Ordered page traversal | | `rank().max()` / `rank().min()` | Extremes by rank order | | `rank().random()` | Random row from ranked set | | `rank().count()` / `rank().sum()` | Ranked-set count/sum | Declaring rankIndex [#declaring-rankindex] Declare a leaderboard index on a table: ```ts title="convex/functions/schema.ts" showLineNumbers const scores = convexTable( 'scores', { gameId: text().notNull(), score: integer().notNull(), createdAt: timestamp().notNull(), userId: text().notNull(), }, (t) => [ rankIndex('leaderboard') .partitionBy(t.gameId) .orderBy({ column: t.score, direction: 'desc' }) .orderBy({ column: t.createdAt, direction: 'asc' }) .sum(t.score), rankIndex('global_leaderboard') .all() .orderBy({ column: t.score, direction: 'desc' }), ] ); ``` `partitionBy(...)` scopes each ranked set to a unique partition value — for example, one leaderboard per game. Ranked Queries [#ranked-queries] Call `rank()` with the index name and a `where` clause that covers the partition fields: ```ts showLineNumbers const leaderboard = ctx.orm.query.scores.rank('leaderboard', { where: { gameId }, }); const top10 = await leaderboard.paginate({ cursor: null, limit: 10 }); const userRank = await leaderboard.indexOf({ id: userId }); const thirdPlace = await leaderboard.at(2); const best = await leaderboard.max(); const worst = await leaderboard.min(); const randomPick = await leaderboard.random(); const total = await leaderboard.count(); const totalScore = await leaderboard.sum(); ``` Leaderboard + User Stats [#leaderboard--user-stats] This is a common pattern: scalar metrics from ORM + ranking from `rank()`: ```ts showLineNumbers {15-20} const gameScores = await ctx.orm.query.scores.findMany({ where: { userId: ctx.userId, gameId: input.gameId }, limit: 1000, }); if (gameScores.length === 0) { return { highScore: 0, averageScore: 0, gamesPlayed: 0, globalRank: null, percentile: 0 }; } const highScore = Math.max(...gameScores.map((s) => s.score)); const averageScore = gameScores.reduce((sum, s) => sum + s.score, 0) / gameScores.length; const lb = ctx.orm.query.scores.rank('leaderboard', { where: { gameId: input.gameId }, }); const globalRank = await lb.indexOf({ id: ctx.userId }); const totalPlayers = await lb.count(); ``` Repair [#repair] If rank or aggregate state gets out of sync after a bad deploy or interrupted write path, you can trigger a full rebuild from the CLI: ```bash kitcn aggregate rebuild ``` Rebuild clears stored state in scheduled batches, so it works on tables of any size. Indexes report `CLEARING` while draining and `BUILDING` while recomputing; wait for `READY` before querying them again. Writes targeting a declared index in `CLEARING` fail before the document write. Retry them after the index advances to `BUILDING` or `READY`. Automatic pruning follows aggregate lifecycle state, so kickoff reads stay bounded as backing tables grow. To recover state-less storage, invoke `generated/aggregate:aggregateBackfill` in `prune` mode with exact `tableName` and `indexName` arguments. Best Practices [#best-practices] Use partitionBy for Isolation [#use-partitionby-for-isolation] ```ts showLineNumbers {2-4,7-9} // ✅ Partition per tenant to isolate write hot spots rankIndex('tenant_scores') .partitionBy(t.tenantId) .orderBy({ column: t.score, direction: 'desc' }); // ❌ Global rank can create cross-tenant contention rankIndex('global_scores') .all() .orderBy({ column: t.score, direction: 'desc' }); ``` Design orderBy for Query Patterns [#design-orderby-for-query-patterns] ```ts showLineNumbers {2-5,8-10} // ✅ Order matches product reads: highest score first, oldest tie first rankIndex('game_scores') .partitionBy(t.gameId) .orderBy({ column: t.score, direction: 'desc' }) .orderBy({ column: t.createdAt, direction: 'asc' }); // ❌ Order does not match "top score" queries rankIndex('game_scores_by_time') .partitionBy(t.gameId) .orderBy({ column: t.createdAt, direction: 'asc' }); ``` When to Use [#when-to-use] | Need | Use | | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | | Counts, sums, averages | [Scalar Metrics](#scalar-metrics) (`aggregateIndex` + ORM `count()`/`aggregate()`) | | Relation counts | [Relation `_count`](#relation-_count) (`with: { _count: { ... } }`) | | Rankings, leaderboards | [Ranked Access With `rankIndex`](#ranked-access-with-rankindex) (`indexOf`, `at`, `paginate`) | | Random document access | [Ranked Access With `rankIndex`](#ranked-access-with-rankindex) (`random`, `at`) | | Sorted pagination | [Ranked Access With `rankIndex`](#ranked-access-with-rankindex) (`paginate({ cursor, limit })`) | | Non-table data | Model it as a table, then use [Scalar Metrics](#scalar-metrics) or [Ranked Access With `rankIndex`](#ranked-access-with-rankindex) | API Reference [#api-reference] Prisma Parity Matrix (No-Scan) [#prisma-parity-matrix-no-scan] | Prisma feature | Status | Notes | | ----------------------------------------------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------- | | `aggregate({ _count/_sum/_avg/_min/_max, where })` | Supported | Bucket-backed, no base-table scan fallback | | `aggregate({ _sum })` nullability | Supported | Returns `null` for empty/all-null sets | | `groupBy({ by, where, _count/_sum/_avg/_min/_max })` | Supported | `by` fields must be finite-constrained (`eq/in/isNull`) in `where` | | `groupBy({ having/orderBy/skip/take/cursor })` | Partial | Supported for finite index-bounded groups with conjunction-only `having` | | `count()` | Supported | Native Convex count syscall | | `count({ where })` | Supported | Indexed scalar subset | | `count({ where, select: { _all, field } })` | Supported | Field counts require `aggregateIndex.count(field)` | | `findMany({ with: { _count: { relation: true } } })` | Supported | Indexed relation counts | | `findMany({ with: { _count: { relation: { where } } } })` | Supported | Direct relation scalar filters | | `aggregate({ orderBy/take/skip/cursor })` | Partial | `orderBy/cursor` supported; `skip/take` is `_count`-only in v1 | | Advanced aggregate/count filters (`OR/NOT/string/relation`) | Partial | Bounded finite DNF `OR` rewrite is supported when branches resolve to one `aggregateIndex`; `NOT`/string/relation filters are blocked | | Relation `_count` nested relation filter | Blocked | `RELATION_COUNT_FILTER_UNSUPPORTED` | | `findMany({ distinct })` | Blocked | Not available under strict no-scan contract. Use `select().distinct({ fields })` | | Relation `_count` filtered through relation | Supported | Indexed `through()` relation filters | | Mutation return `_count` parity | Supported | `returning({ _count })` on insert/update/delete | Windowed Count [#windowed-count] You can count rows within a window defined by ordering and bounds: ```ts showLineNumbers // Count with skip/take (pagination window) const windowCount = await ctx.orm.query.todos.count({ where: { projectId }, orderBy: { createdAt: 'desc' }, skip: 10, take: 50, }); // Count with cursor (rows after a specific value) const afterCursor = await ctx.orm.query.todos.count({ where: { projectId }, orderBy: { createdAt: 'desc' }, cursor: { createdAt: lastSeen }, }); ``` * `orderBy` — sort direction for window bounds * `skip` — number of rows to skip before counting * `take` — max rows to count within the window * `cursor` — start counting after a specific field value (requires `orderBy`, single field in v1) }> `count({ select: { field: true } })` with `skip`/`take`/`cursor` is not supported in v1. Use `count()` or `count({ select: { _all: true } })` for windowed counts. Windowed Aggregate [#windowed-aggregate] `aggregate()` also supports window args with the same strict no-scan guardrails: * `orderBy` + `cursor`: supported for `_count/_sum/_avg/_min/_max` * `skip`/`take`: supported for `_count` only in v1 ```ts showLineNumbers const metrics = await ctx.orm.query.orders.aggregate({ where: { orgId: 'org-1' }, orderBy: { amount: 'desc' }, skip: 10, take: 100, _sum: { amount: true }, _count: { _all: true }, }); ``` `skip`/`take` with non-count metrics throws `AGGREGATE_ARGS_UNSUPPORTED` because metric window skip/take is not bucket-computable under strict no-scan in v1. Error Codes [#error-codes] | Error | Cause | | -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `COUNT_NOT_INDEXED` | No `aggregateIndex` matches the filter shape | | `COUNT_FILTER_UNSUPPORTED` | Filter uses unsupported operators (`NOT`, string, relation, or non-finite `OR`), or a range count/aggregate exceeds `aggregateWorkBudget` work units. Bucket scans cost one unit; `_min` and `_max` also cost one extrema read per matching bucket. | | `COUNT_INDEX_BUILDING` | Index is still backfilling | | `COUNT_RLS_UNSUPPORTED` | Called in an RLS-restricted context | _sum Nullability [#_sum-nullability] `_sum` returns `null` for empty sets or when all field values are `null` (Prisma-compatible): ```ts showLineNumbers // Empty table or all-null amounts → { _sum: { amount: null } } // Non-empty with values → { _sum: { amount: 1500 } } ``` Limitations [#limitations] | Consideration | Guideline | | --------------------- | ------------------------------------------------------------------ | | Update frequency | High-frequency updates to nearby keys cause contention | | Key size | Keep composite keys reasonable (3-4 components max) | | Partition cardinality | High-cardinality `partitionBy` values increase rank index overhead | | Query patterns | Design keys for actual needs, not hypothetical | findMany({ distinct }) (Unsupported) [#findmany-distinct--unsupported] `findMany({ distinct })` is not available to keep strict index-backed no-scan guarantees. If you need deduplication in query pipelines, use: ```ts showLineNumbers const page = await ctx.orm.query.todos .select() .distinct({ fields: ['status'] }) .paginate({ cursor: null, limit: 100 }); ``` Next Steps [#next-steps] # Filters (/docs/orm/queries/filters) import { InfoIcon } from "lucide-react" Filtering + Pagination Compatibility [#filtering--pagination-compatibility] Pagination and filtering are tightly coupled. For the full compatibility matrix, see [API Reference](#api-reference) below. How to choose [#how-to-choose] Use this sequence: 1. Need relevance-ranked text search? Use `search`. 2. Need vector similarity? Use `vectorSearch`. 3. Need relation-aware filtering? Use object `where`. 4. Need Drizzle callback syntax for base-table filters? Use callback `where`. 5. Need custom JS predicate logic? Use callback `where` + `predicate(...)` with explicit `.withIndex(...)`. 6. Need advanced pre-pagination composition (union/interleave/map/filter/flatMap/distinct)? Use the `select()` composition chain. }> **Note:** The ORM supports search via `findMany({ search: { index, query, filters? } })`. Function `where` forms (callback and predicate) cannot be combined with search mode. Select Composition Filtering (Advanced) [#select-composition-filtering-advanced] Most filtering should stay in plain `findMany`. Use `select()` composition when you need stream-style transforms before pagination. This example uses a union + interleave (internal merged-stream semantics), then applies `filter` and `map` before returning the page: ```ts title="convex/functions/messages.ts" showLineNumbers {17-45} import * as z from 'zod'; import { publicQuery } from '../lib/crpc'; const MessageSchema = z.object({ id: z.string(), from: z.string(), to: z.string(), body: z.string(), deletedAt: z.number().nullable(), }); export const listConversation = publicQuery .input(z.object({ me: z.string(), them: z.string() })) .paginated({ limit: 20, item: MessageSchema }) .query(async ({ ctx, input }) => { return await ctx.orm.query.messages .select() .union([ { index: { name: 'by_from_to', range: (q) => q.eq('from', input.me).eq('to', input.them), }, }, { index: { name: 'by_from_to', range: (q) => q.eq('from', input.them).eq('to', input.me), }, }, ]) .interleaveBy(['createdAt', 'id']) .filter(async (m) => !m.deletedAt) .map(async (m) => ({ ...m, body: m.body.slice(0, 240) })) .paginate({ cursor: input.cursor, limit: input.limit, maxScan: 500, }); }); ``` Each source pins `from` and `to` with `eq`, which is what leaves both ordered by `createdAt, id` — the fields `interleaveBy` merges on. A source may also carry a `where` instead of an `index`. It is compiled against the table's indexes the same way a `findMany` where is, so `{ where: { status: 'active' } }` reads the `by_status` range rather than filtering a scan. Two rules bound that: a `where` is never lowered onto an index that the source or the chain already pinned with a range, and it is only lowered when the result can still supply `interleaveBy`. Anchor a source with `index: { name, range }` whenever you need a specific range read. If you need page-boundary pinning in reactive UIs, pass `endCursor` on the follow-up call. Full-Text Search [#full-text-search] Full-text search enables searching string fields with relevance scoring. Each search index can search ONE field, with optional filter fields for equality filtering. Schema Definition [#schema-definition] Add search indexes to your schema using the naming pattern `search_`: ```ts title="convex/functions/schema.ts" showLineNumbers {8-12,14-16} import { convexTable, defineSchema, searchIndex, text } from 'kitcn/orm'; export const articles = convexTable( 'articles', { title: text().notNull(), content: text().notNull(), author: text().notNull(), category: text().notNull(), }, (t) => [ // Search content with category/author as filter fields searchIndex('search_content').on(t.content).filter(t.category, t.author), // Separate index for title search searchIndex('search_title').on(t.title), ] ); export const tables = { articles }; export default defineSchema(tables, { strict: false }); ``` }> **Note:** Each search index can only search ONE field. Add `filterFields` for fast equality filtering before text search. Basic Search [#basic-search] Basic search query: ```ts title="convex/functions/articles.ts" showLineNumbers {7-11} import * as z from 'zod'; import { publicQuery } from '../lib/crpc'; export const search = publicQuery .input(z.object({ query: z.string(), limit: z.number().min(1).max(100).default(10), })) .output(z.array(z.object({ id: z.string(), title: z.string(), content: z.string(), }))) .query(async ({ ctx, input }) => { return await ctx.orm.query.articles.findMany({ search: { index: 'search_content', query: input.query, }, limit: input.limit, }); }); ``` }> **Important:** Always use `limit` or cursor pagination (`cursor` + `limit`) with search queries to limit results. Search with Filters [#search-with-filters] Filter fields enable fast pre-filtering before text search. Only equality filters are supported: ```ts title="convex/functions/articles.ts" showLineNumbers {8-16} export const searchByCategory = publicQuery .input(z.object({ query: z.string(), category: z.string(), author: z.string().optional(), })) .output(z.array(z.object({ id: z.string(), title: z.string(), content: z.string(), category: z.string(), }))) .query(async ({ ctx, input }) => { return await ctx.orm.query.articles.findMany({ search: { index: 'search_content', query: input.query, filters: { category: input.category, ...(input.author ? { author: input.author } : {}), }, }, limit: 20, }); }); ``` Paginated Search [#paginated-search] Use `.paginated({ limit, item })` ([paginated builder](/docs/server/procedures#paginated-queries)) for cursor-based pagination: ```ts title="convex/functions/articles.ts" showLineNumbers {3,9-17} const ArticleSchema = z.object({ id: z.string(), title: z.string(), content: z.string(), category: z.string(), }); export const searchPaginated = publicQuery .input(z.object({ query: z.string(), category: z.string().optional(), })) .paginated({ limit: 20, item: ArticleSchema }) .query(async ({ ctx, input }) => { return await ctx.orm.query.articles.findMany({ search: { index: 'search_content', query: input.query, filters: input.category ? { category: input.category } : undefined, }, cursor: input.cursor, limit: input.limit, }); }); ``` }> **Tip:** Search results are automatically sorted by relevance. Callback where (Drizzle Style) [#callback-where-drizzle-style] Use callback `where` when you want Drizzle-like expression syntax with typed operators: ```ts title="convex/functions/users.ts" showLineNumbers {4-7} const admins = await db.query.users.findMany({ where: (users, { and, eq, isNotNull }) => and(eq(users.role, 'admin'), isNotNull(users.email)), }); ``` This mode compiles through the same expression planner as object `where`, so it can use indexes when possible. Predicate where (Explicit Index Required) [#predicate-where-explicit-index-required] Use a predicate `where` for complex JS logic. You must provide an explicit `.withIndex(...)`. }> **Disclaimer:** Use this while prototyping or when you know the query won't need to scale.\ If you need to scale, prefer index-compiled filters and explicit index design. ```ts title="convex/functions/characters.ts" showLineNumbers {6-8,13-18,21-24} const CharacterSchema = z.object({ id: z.string(), name: z.string(), categories: z.array(z.string()).nullable(), score: z.number(), }); export const searchCharacters = publicQuery .input(z.object({ category: z.string().optional(), minScore: z.number().optional(), })) .paginated({ limit: 20, item: CharacterSchema }) .query(async ({ ctx, input }) => { return await ctx.orm.query.characters .withIndex('private', (q) => q.eq('private', false)) .findMany({ where: (_characters, { predicate }) => predicate((char) => { if (input.category && !char.categories?.includes(input.category)) { return false; } if (input.minScore && char.score < input.minScore) { return false; } return true; }), cursor: input.cursor, limit: input.limit, }); }); ``` }> **Tip:** Prefer index‑friendly object `where` for most queries. Use predicate `where` only when you need complex JS logic and can anchor it with `.withIndex(...)`. Search vs Predicate where [#search-vs-predicate-where] Comparison: | Feature | Search | Predicate `where` | | --------------- | -------------------------------------------- | ------------------------------------------------- | | Text matching | Full-text search with relevance | No text search | | Complex filters | `filterFields` only (equality) | Full TypeScript | | Range queries | No | Yes | | Pagination | Standard | Works with cursor pagination (`cursor` + `limit`) | | UNION/JOIN | No | Manual merge in app code | | Performance | Optimized for text | Depends on chosen index + predicate selectivity | | **Combined** | **Supported with base-field object `where`** | Predicate / relation `where` still unsupported | Choosing the Right Approach [#choosing-the-right-approach] 1. **Need text search?** → Use search indexes 2. **Need complex per-row JS logic?** → Use predicate `where` + explicit `.withIndex(...)` 3. **Need range queries (greater than, less than)?** → Use object/callback expression `where` (index-compiled when possible) 4. **Need UNION or JOIN operations?** → Merge in application code 5. **Need text search + relation/predicate filters?** → See options below Combining Search and Complex Filters [#combining-search-and-complex-filters] Search mode supports `search.filters` plus base-table object `where`. For predicate `where` or relation-based `where`, use one of these patterns. **Option 1: Add more filterFields** (recommended) Add equality filters to your search index: ```ts showLineNumbers {2-3} searchIndex('search_content') .on(t.content) .filter(t.category, t.author, t.status, t.dateGroup); ``` **Option 2: Separate query paths** Use search when text is provided, predicate filtering otherwise: ```ts showLineNumbers {7-8,10-19,21-30} export const searchOrFilter = publicQuery .input(z.object({ query: z.string().optional(), category: z.string().optional(), startDate: z.number().optional(), })) .paginated({ limit: 20, item: ArticleSchema }) .query(async ({ ctx, input }) => { if (input.query) { // Text search path - limited filtering return await ctx.orm.query.articles.findMany({ search: { index: 'search_content', query: input.query!, filters: input.category ? { category: input.category } : undefined, }, cursor: input.cursor, limit: input.limit, }); } // Predicate where path - full filtering power with explicit .withIndex(...) return await ctx.orm.query.articles .withIndex('by_creation_time') .findMany({ where: (_articles, { predicate }) => predicate((article) => { if (input.category && article.category !== input.category) return false; if (input.startDate && article.publishedAt < input.startDate) return false; return true; }), cursor: input.cursor, limit: input.limit, }); }); ``` **Option 3: Post-process search results** (small datasets only) ```ts showLineNumbers {2-4,6-8} // Only viable for small result sets const searchResults = await ctx .orm.query.articles.findMany({ search: { index: 'search_content', query }, limit: 100, // Hard limit }); const filtered = searchResults.filter((article) => article.publishedAt >= startDate && article.publishedAt <= endDate ); ``` Performance Considerations [#performance-considerations] Search Performance [#search-performance] * Search indexes have overhead - don't over-index * Use `filterFields` to narrow results before text matching * Always limit results with `limit` or cursor pagination (`cursor` + `limit`) Predicate where Performance [#predicate-where-performance] Predicate `where` runs post‑fetch against an explicit index stream. Use `maxScan` to cap scan size: ```ts showLineNumbers {2-6} const results = await db.query.posts .withIndex('by_created_at') .findMany({ where: (_posts, { predicate }) => predicate((post) => expensiveCheck(post)), cursor: input.cursor, limit: input.limit, maxScan: 500, }); ``` }> **Warning:** Predicate filters can scan a large portion of a table. Keep page sizes small and use indexes when possible. Cursor Stability [#cursor-stability] Cursor pagination is only stable when you reuse the same `where` predicate and `orderBy`: ```ts showLineNumbers {1-6} const page1 = await db.query.messages .withIndex('by_creation_time') .findMany({ where: (_messages, { predicate }) => predicate((m) => m.read), orderBy: { createdAt: 'desc' }, cursor: null, limit: 20, }); ``` Memory Efficiency [#memory-efficiency] * Prefer cursor pagination (`cursor` + `limit`) for user‑facing lists * Keep limits small for non‑paginated queries * Use selective index ranges for predicate `where` and cap reads with `maxScan` Best Practices [#best-practices] API Reference [#api-reference] Filtering + Pagination Compatibility Matrix [#filtering--pagination-compatibility-matrix] | Query mode | Index requirement | Pagination support | Relation-aware filtering | Ordering | | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | | `findMany({ where: object })` | Optional. Planner uses indexes when possible; otherwise it runs a bounded scan. Some post-fetch operators are typed to require `.withIndex(...)`. | `limit/offset`, `cursor + limit` | Yes (`where: { relation: ... }`) | `orderBy` supported; first field drives cursor stability | | `findMany({ where: (table, ops) => ... })` | Optional. Planner uses indexes when possible; otherwise it runs a bounded scan. Some post-fetch operators are typed to require `.withIndex(...)`. | `limit/offset`, `cursor + limit` | No (base-table expression only) | `orderBy` supported; first field drives cursor stability | | `findMany({ where: (_table, { predicate }) => predicate(...) })` | Required explicit `.withIndex(name, range?)` | `limit/offset`, `cursor + limit`, optional `maxScan` (cursor mode) | No (predicate replaces object relation filter path) | Index/stream-backed; first order field matters for cursor mode | | `findMany({ search })` | Required `searchIndex` | `limit/offset`, `cursor + limit` | No relation filters (base-table object `where` only) | Relevance order only (`orderBy` unsupported) | | `findMany({ vectorSearch })` | Required `vectorIndex` + configured provider | `vectorSearch.limit` only (no `cursor`, `offset`, top-level `limit`) | No | Similarity order only | | `select()..paginate({ cursor, limit })` | Schema + index/range path per union source (or inferred base path) | `cursor + limit` (+ optional `endCursor`, `maxScan`) or non-cursor | Manual via composition chain (`filter`, `map`, `flatMap`, `distinct`, `union`, `interleaveBy`) | Stream-backed index order | Next Steps [#next-steps] # Queries (/docs/orm/queries) import { InfoIcon, AlertTriangle } from "lucide-react" Setup [#setup] These examples assume you attached ORM to `ctx.orm` once in your context (see [/docs/quickstart#orm-setup](/docs/quickstart#orm-setup)): ```ts showLineNumbers title="convex/queries.ts" await ctx.orm.query.users.findMany({ limit: 10 }); ``` Basic Queries [#basic-queries] findMany [#findmany] Use `findMany()` to retrieve multiple rows: ```ts title="convex/functions/users.ts" showLineNumbers import { publicQuery } from '../lib/crpc'; export const getAllUsers = publicQuery.query(async ({ ctx }) => { return ctx.orm.query.users.findMany({ limit: 50 }); }); ``` }> **Note**: Non-paginated `findMany()` requires explicit sizing: provide `limit`, use cursor pagination (`cursor` + `limit`), set `allowFullScan`, or configure `defineSchema(..., { defaults: { defaultLimit } })`. findFirst [#findfirst] Use `findFirst()` to retrieve a single row matching your criteria. This example uses `id` which has a built-in index: ```ts title="convex/functions/users.ts" showLineNumbers import * as z from 'zod'; import { publicQuery } from '../lib/crpc'; export const getFirstUser = publicQuery .input(z.object({ userId: z.string() })) .query(async ({ ctx, input }) => { return ctx.orm.query.users.findFirst({ where: { id: input.userId }, }); }); ``` If you expect a row to exist, use `findFirstOrThrow()`: ```ts showLineNumbers const user = await ctx.orm.query.users.findFirstOrThrow({ where: { id: input.userId }, }); ``` For tables with a `discriminator()` discriminator column, query results automatically include a typed discriminated union at the alias field (default `details`). Use `withVariants: true` when you want all `one()` relations eagerly loaded for those variants. See [Polymorphic Associations](/docs/orm/schema/relations#polymorphic-associations) in the Relations guide. Filtering With where [#filtering-with-where] Use object filters (Drizzle style) to narrow results. The shape mirrors Drizzle but is executed in Convex: ```ts showLineNumbers {2-5} // Schema: index('by_role').on(t.role) on users table const admins = await db.query.users.findMany({ where: { role: 'admin', age: { gt: 18 }, }, }); ``` Callback `where` is also supported for Drizzle-style operator syntax: ```ts showLineNumbers {2-3} const admins = await db.query.users.findMany({ where: (users, { eq }) => eq(users.role, 'admin'), }); ``` There are a few important differences from SQL-based Drizzle to keep in mind. SQL-only RQB features like `RAW` filters and `sql.placeholder(...)` are not supported in Convex. The `extras` option is supported, but computed post-fetch (no SQL computed fields). Predicate `where` (function form) runs via stream filtering and requires an explicit `.withIndex(name, range?)` on the query. There is no implicit full-scan fallback. }> **Important**: When using `ops.predicate(...)`, call `.withIndex(...)` first. Without it, the query fails rather than silently scanning every document. }> **Disclaimer:** Use predicate filters while prototyping, or when result volume is known to stay small.\ For scalable paths, prefer index-compiled filters and index-first query design. Core Operators [#core-operators] For the complete operator list, see [API Reference](/docs/orm/api-reference). String Operators [#string-operators] The ORM supports familiar string matching operators. Most run post-fetch, but a few can leverage indexes. For the full operator list, see [API Reference](/docs/orm/api-reference). For large datasets, prefer indexed filters first. `startsWith` and `like('prefix%')` use index ranges when the field is indexed. `between` and `notBetween` are also index-compiled when the field is indexed. Compound indexes follow Convex prefix rules for index compilation. For an index on `[type, numLikes]`, `where: { numLikes: 10 }` can run, but it won't use that compound index prefix unless you also constrain the leading field (`type`) or explicitly anchor the query with `.withIndex(...)`. Reversed `AND` equality order is normalized to index field order before query execution. }> **Tip**: Always constrain leading index fields first. For compound indexes, you must include earlier fields before later ones can be index-compiled. SQL Subquery Operators (exists, notExists) [#sql-subquery-operators-exists-notexists] Drizzle exposes `exists(query)` and `notExists(query)` as SQL subquery operators. The ORM does not support SQL subqueries at runtime, so these operators are unavailable. Use [relation filters](#relation-filters) to model existence checks instead. Aggregations [#aggregations] The ORM supports `count()`, `aggregate()` (sum, avg, min, max), and ranked aggregate runtime — all backed by `aggregateIndex` for strict no-scan performance. See [Aggregates](/docs/orm/queries/aggregates) for full API, index declarations, and aggregate runtime setup. Full-Scan Operators and Workarounds [#full-scan-operators-and-workarounds] These operators are post-fetch. In the typed API, they currently require explicit `.withIndex(...)` so scan scope is deliberate. For the full list with scalable workarounds for each, see [API Reference](/docs/orm/api-reference). Logical Filters [#logical-filters] Combine operators with `OR` and `NOT` for complex conditions: ```ts showLineNumbers {3-4} const users = await db.query.users.findMany({ where: { OR: [{ role: 'admin' }, { role: 'premium' }], NOT: { email: { isNull: true } }, }, }); ``` Relation Filters [#relation-filters] You can filter by relation existence or by nested relation conditions: ```ts showLineNumbers {3,8} // Users with at least one post const users = await db.query.users.findMany({ where: { posts: true }, }); // Users with posts whose title starts with "A" const users2 = await db.query.users.findMany({ where: { posts: { title: { like: 'A%' } } }, }); ``` Ordering [#ordering] Control the sort order of your results with `orderBy`. You can use object syntax: ```ts showLineNumbers {2} const posts = await db.query.posts.findMany({ orderBy: { createdAt: 'desc', title: 'asc' }, }); ``` You can also use callback syntax (Drizzle-style) with `asc`/`desc` helpers: ```ts showLineNumbers {2-5} const posts2 = await db.query.posts.findMany({ orderBy: (posts, { desc, asc }) => [ desc(posts.createdAt), asc(posts.title), ], }); ``` **Notes:** * An index that walks in the requested order serves the sort directly, so `limit` reads only the rows it returns. That needs the sort fields to be the index's leading keys, in order, all pointing the same way — `index('by_type_likes').on(t.type, t.numLikes)` serves `orderBy: { type: 'asc', numLikes: 'asc' }`. Fields pinned by an equality in `where` are constant across the scan, so they can appear anywhere in the sort and in either direction * When the first requested field is equality-pinned but points opposite to the moving fields, include `createdAt` in the moving direction as the final sort field to make the implicit tie-break explicit. Without it, the ORM post-fetch sorts to preserve which tied rows survive `limit` * Every unpinned declared index key must be requested. An extra key after the requested fields would break ties before Convex's implicit creation-time key, so the ORM keeps the post-fetch sort to preserve which rows survive `limit` * `createdAt` is servable as the last sort field once every other index key is pinned or consumed, because Convex appends it as the implicit trailing key * Non-null values use Convex value ordering on both index-backed and post-fetch paths, including UTF-8 string order and Float64 edge values such as signed zero and NaN * Any other sort — mixed directions, a field the index does not sort by next, or a column that can be missing or `null` — is applied post-fetch. The ORM then sorts before applying `offset`/`limit`, which requires reading the full filtered candidate set first * `findMany({ cursor, limit })` orders pages by every `orderBy` field the index serves. When the index cannot serve them all, only the first field orders the cursor and the rest are dropped, which the ORM warns about Pagination [#pagination] Limit / Offset [#limit--offset] For simple pagination, use `limit` and `offset`: ```ts showLineNumbers {2-3} const posts = await db.query.posts.findMany({ limit: 10, offset: 20, }); ``` Cursor Pagination [#cursor-pagination] For efficient pagination over large datasets, use cursor-based pagination: ```ts showLineNumbers {3-4} return db.query.posts.findMany({ where: { published: true }, orderBy: { createdAt: 'desc' }, cursor: args.cursor ?? null, limit: 20, }); ``` `in`, `notIn`, `ne`, and same-field equality `OR` on a field an index leads with page from one index range per value, so they need no scan budget. For cursor queries that fall back to a scan (predicate `where` or post-filter-only plans), use `maxScan`. `allowFullScan` is non-cursor only. With `strict: true`, missing `maxScan` on these fallback paths throws; with `strict: false`, it warns and runs uncapped. Full-Text Search [#full-text-search] Use `search` on `findMany()` / `findFirst()` when your table defines a `searchIndex`: ```ts showLineNumbers {2-6} const posts = await db.query.posts.findMany({ search: { index: 'text_search', query: 'galaxy', filters: { type: 'news' }, }, cursor: null, limit: 20, }); ``` `search.filters` is typed from the selected index `filterFields`. `search` is only available on tables that declare at least one `searchIndex`. **Search mode constraints** * `orderBy` is not allowed (Convex relevance ordering is used) * callback `where` (`(table, ops) => ...`) is not allowed * relation-based `where` is not allowed * object `where` on base table fields is allowed (post-search filter) * `with:` is allowed for eager loading System Tables (db.system passthrough) [#system-tables-dbsystem-passthrough] Use `db.system` for Convex system tables (`_storage`, `_scheduled_functions`). This is raw Convex access, not ORM query-builder behavior: ```ts showLineNumbers const job = await db.system.get(jobId); const files = await db.system .query('_storage') .take(20); const pendingJobs = await db.system .query('_scheduled_functions') .collect(); ``` }> **Important**: `db.system` supports raw Convex reader methods like `get` and `query`. ORM features such as `findMany`/`findFirst`, relation loading (`with:`), and ORM-specific rule helpers do not apply. Vector Search [#vector-search] Use `vectorSearch` on `findMany()` when your table defines a `vectorIndex`: ```ts showLineNumbers {2-8} const posts = 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 }, }); ``` `vectorSearch.index` and `vectorSearch.filter` are strongly typed from your vector index definition. `vectorSearch.includeScore: true` opt-in adds `_score` to each returned row. **Vector mode constraints** * `vectorSearch.limit` is required (`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`) * `_score` is returned only when `vectorSearch.includeScore: true` * `with:`, `columns`, and `extras` are allowed Choosing Filter vs Paginate [#choosing-filter-vs-paginate] Use this decision matrix to pick the right approach and avoid accidental full scans: | Goal | Recommended Tool | Notes | | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------- | | Simple filters, no pagination | `db.query.*.findMany({ where, limit })` | Uses index compilation when possible; non-paginated reads must be sized | | Complex JS predicate, no pagination | `db.query.*.withIndex('by_field').findMany({ where: (_t, { predicate }) => predicate((row) => ...), limit: 100 })` | Explicit index required; non-paginated reads must be sized | | Complex JS predicate + pagination | `db.query.*.withIndex('by_field').findMany({ where: (_t, { predicate }) => predicate((row) => ...), cursor, limit, maxScan })` | Explicit index + bounded scan | | Large lists | `db.query.*.findMany({ cursor: null, limit: 20 })` | First `orderBy` field should be indexed | | Search + equality filters | `db.query.*.findMany({ search: { index, query, filters? } })` | `filters` must be in search index `filterFields` | | Embedding similarity | `db.query.*.findMany({ vectorSearch: { index, vector, limit, includeScore?, filter? } })` | Similarity order from vector search; no `cursor`/`where`/`orderBy` | Relation Loading With with: [#relation-loading-with-with] Eager-load related data to avoid N+1 queries: ```ts showLineNumbers {2-7} const users = await db.query.users.findMany({ with: { posts: { limit: 5, offset: 2, orderBy: { createdAt: 'desc' }, }, }, }); ``` Relation filters for `many()` are applied post-fetch. Nested `with:` loads every level you ask for, up to 10 levels deep; a tree that still has rows past that throws `RELATION_DEPTH_EXCEEDED` rather than coming back shorter than you asked for. The `limit` and `offset` in `with:` apply per parent relation, not globally. }> **Note**: For `many()` relations, provide `with..limit` or set `defineSchema(..., { defaults: { defaultLimit } })`. Otherwise, use `allowFullScan` on the parent query. Column Selection [#column-selection] Select only the columns you need with `columns`: ```ts showLineNumbers {2} const users = await db.query.users.findMany({ columns: { name: true, email: true }, }); ``` }> **Note**: `columns` is a post-fetch projection. Convex still reads full documents. Distinct (findMany Unsupported) [#distinct-findmany-unsupported] `findMany({ distinct })` is not available to keep strict index-backed no-scan guarantees. Use select-pipeline distinct instead: ```ts showLineNumbers const page = await ctx.orm.query.todos .select() .where({ projectId }) .distinct({ fields: ['status'] }) .paginate({ cursor: null, limit: 100 }); ``` Extras (Computed Fields) [#extras-computed-fields] `extras` lets you attach computed properties to rows at query time: ```ts showLineNumbers {2-4} const users = await db.query.users.findMany({ extras: { emailDomain: (row) => row.email.split('@')[1]!, }, }); ``` Callback form is also supported: ```ts showLineNumbers {2-4} const users = await db.query.users.findMany({ extras: () => ({ emailDomain: (row) => row.email.split('@')[1]!, }), }); ``` Extras are computed in JavaScript after fetching documents (and after `with:` relations are loaded). They can't be used in `where`/`orderBy` and should be treated as post-fetch helpers. Performance Tips [#performance-tips] * Add `index('...').on(t.field)` for fields used in filters or primary ordering * Use cursor pagination (`cursor` + `limit`) for large lists * Prefer `with:` over per-row queries to avoid N+1 Common Gotchas [#common-gotchas] | Issue | Fix | | ------------------------------ | ------------------------------------------------------------------------------------------ | | `where` callback fails | Return a filter expression: `where: (table, { eq }) => eq(table.field, value)` | | `eq(field, null)` | Use `{ isNull: true }` | | Slow ordering | Put the primary `orderBy` field in an index, immediately after the fields the `where` pins | | `columns` doesn't reduce reads | Projection is post-fetch | | Relations not loading | Ensure relations are defined on both sides (or explicit `from`/`to`) | Next Steps [#next-steps] Learn insert, update, and delete operations Define and load relationships between tables Deep dive into table definitions and type inference Understand current limitations # Operators (/docs/orm/queries/operators) import { InfoIcon } from "lucide-react" Query where (Object Filters) [#query-where-object-filters] Object filters are the primary query mechanism for ORM relational queries. You can combine equality checks, comparison operators, and logical combinators in a single `where` object. Combine equality checks, comparison operators, and logical combinators in a single `where` object: ```ts showLineNumbers {2-4} await db.query.users.findMany({ where: { OR: [{ role: 'admin' }, { role: 'member' }], age: { gt: 18 }, }, }); ``` For the full list of supported operators, see [API Reference](#supported-operators) below. String Operators (post-fetch) [#string-operators-post-fetch] The ORM supports Drizzle-style `like`/`ilike` operators and evaluates them **post-fetch**. Search for posts containing "javascript" (case-insensitive): ```ts showLineNumbers {2} await db.query.posts.withIndex('by_title').findMany({ where: { title: { ilike: '%javascript%' } }, }); ``` You can also use convenience operators for common patterns: ```ts showLineNumbers {2} await db.query.users.withIndex('by_email').findMany({ where: { email: { endsWith: '@example.com' } }, }); ``` The supported string operators are: * `like`, `ilike`, `notLike`, `notIlike` * `startsWith`, `endsWith`, `contains` Array Operators (post-fetch) [#array-operators-post-fetch] If a field is an array (for example, embeddings or other array-like values), you can use: * `arrayContains` * `arrayContained` * `arrayOverlaps` Mutation where (Filter Expressions) [#mutation-where-filter-expressions] Mutation builders (`insert`, `update`, `delete`) use operator helpers that return a `FilterExpression`. These helpers accept **column builders** (like `users.role`) and use builder typing to infer the right value type. Update all users with a specific role who are over 18: ```ts showLineNumbers {3-6} import { and, eq, gt } from 'kitcn/orm'; // Schema: index('by_role').on(t.role) on users table await ctx.orm.update(users).set({ role: 'admin' }).where( and( eq(users.role, 'member'), gt(users.age, 18), ) ); ``` For the full list of mutation/RLS helpers, see [API Reference](#common-helpers) below. Unsupported SQL-only Features [#unsupported-sql-only-features] These SQL-specific features are not available in the ORM: * `RAW` filters and SQL placeholders (`sql.placeholder(...)`) * Prepared statements * SQL subquery operators `exists` / `notExists` (Convex has no SQL subquery runtime) For existence checks, use relation filters instead: ```ts showLineNumbers // Users with posts await db.query.users.findMany({ where: { posts: true }, }); // Users without posts await db.query.users.findMany({ where: { NOT: { posts: true } }, }); ``` API Reference [#api-reference] Supported Operators [#supported-operators] Full list of operators available inside a `where` object: ```ts showLineNumbers where: { OR: [], AND: [], NOT: {}, // relations (filter by existence or nested filters) posts: true, // columns age: 15, age: { eq: 15 }, age: { ne: 15 }, age: { gt: 15 }, age: { gte: 15 }, age: { lt: 15 }, age: { lte: 15 }, age: { between: [18, 65] }, age: { notBetween: [18, 65] }, role: { in: ['admin', 'member'] }, role: { notIn: ['banned'] }, email: { isNull: true }, email: { isNotNull: true }, } ``` }> **Note:** `between` is inclusive (`>= min` and `<= max`). `notBetween` is strict outside-range (`< min` or `> max`). Common Helpers [#common-helpers] All available mutation/RLS operator helpers: | Category | Helpers | | ---------- | ------------------------------------ | | Comparison | `eq`, `ne`, `gt`, `gte`, `lt`, `lte` | | Range | `between`, `notBetween` | | Set | `inArray`, `notInArray` | | Logical | `and`, `or`, `not` | | Null | `isNull`, `isNotNull` | Next Steps [#next-steps] # Pagination (/docs/orm/queries/pagination) import { AlertTriangle, InfoIcon } from "lucide-react" In this guide, we'll choose the right pagination mode for your query shape and data size. We'll start with a quick comparison, then walk through each mode with practical examples. The ORM exposes pagination through `findMany` only. Quick Recommendation [#quick-recommendation] If you just want the default that scales: 1. Start with cursor pagination: `findMany({ cursor, limit })`. 2. Add `endCursor` when you need boundary pinning during reactive refreshes. 3. Use `select()` composition for advanced pre-pagination transforms (union/interleave/filter/map/flatMap/distinct). 4. Use `pageByKey` when you need deterministic key boundaries. 5. Use `offset` only for shallow page-number UIs. Pagination Comparison [#pagination-comparison] | Mode | API | Best for | Supports `endCursor` | Supports `maxScan` | Notes | | ----------- | --------------------------------------------------------------------------------- | -------------------------------------------------- | -------------------- | ---------------------- | ----------------------------------------------------- | | Offset | `findMany({ offset, limit })` | Page-number UIs, small datasets | No | No | Simple, but slower at large offsets | | Cursor | `findMany({ cursor, limit })` | Infinite scroll and large lists | Yes | Yes (cursor mode only) | Efficient and stable when order/index are aligned | | Composition | `select().map/filter/flatMap/union/interleaveBy(...).paginate({ cursor, limit })` | Advanced stream-like composition before pagination | Yes | Yes | Supports union/interleave/map/filter/flatMap/distinct | | Key-based | `findMany({ pageByKey })` | Deterministic key boundaries and adjacency control | N/A | N/A | Returns `{ page, indexKeys, hasMore }` | Offset Pagination [#offset-pagination] Offset pagination is the most straightforward option: ```ts showLineNumbers const rows = await db.query.users.findMany({ orderBy: { createdAt: 'desc' }, offset: 20, limit: 10, }); ``` Use this when users need direct jumps like page 8, page 9, page 10. Avoid deep offsets on large tables because the query still has to read through earlier rows. Cursor Pagination [#cursor-pagination] Cursor pagination is the default for scalable list UIs. First page: ```ts showLineNumbers const page1 = await db.query.posts.findMany({ where: { published: true }, orderBy: { createdAt: 'desc' }, cursor: null, limit: 20, }); ``` Next page: ```ts showLineNumbers const page2 = await db.query.posts.findMany({ where: { published: true }, orderBy: { createdAt: 'desc' }, cursor: page1.continueCursor, limit: 20, }); ``` Return shape: ```ts showLineNumbers { page: T[]; continueCursor: string | null; isDone: boolean; pageStatus?: 'SplitRecommended' | 'SplitRequired'; splitCursor?: string; } ``` Boundary pinning with endCursor [#boundary-pinning-with-endcursor] When data changes between requests, use `endCursor` to pin a page boundary: ```ts showLineNumbers const refreshed = await db.query.posts.findMany({ where: { published: true }, orderBy: { createdAt: 'desc' }, cursor: null, endCursor: page1.continueCursor, limit: 20, }); ``` This keeps adjacent pages stitched correctly as new rows arrive. Index-union filters [#index-union-filters] `in`, `notIn`, `ne`, and same-field equality `OR` compile to one index range per value when the field is one an index leads with. Cursor pages come from those ranges, so the read is bounded by the matches and needs no scan budget: ```ts showLineNumbers const page = await db.query.users .withIndex('by_status') .findMany({ where: { status: { in: ['active', 'pending'] } }, orderBy: { createdAt: 'desc' }, cursor: null, limit: 20, }); ``` Up to 64 ranges are read as one merged stream, so `orderBy` still sorts across the whole result, not per value. A wider union is read one range after another instead, which keeps it index-bounded at any list length but can only produce the index's own order. Two shapes fall back to a bounded scan and need `maxScan`: * An `orderBy` the probed index cannot supply. The index has to lead with the probed field, so only that field, the next index field after it, or `createdAt` on a single-field index is servable. * An `orderBy` that sorts across values, such as `createdAt`, on a union wider than 64 ranges. Interleaving values needs all their ranges open at once, which is the fan-out that cap refuses. Without an `orderBy`, an index-union page is in the order of the index it walks — grouped by the probed value — not in creation order. Pass `orderBy: { createdAt: 'desc' }` when you want newest-first across values. Scan budget with maxScan [#scan-budget-with-maxscan] `maxScan` is available only in cursor mode. It caps scanned rows instead of throwing when a page would scan too much. ```ts showLineNumbers const page = await db.query.users .withIndex('by_email') .findMany({ where: (_users, { predicate }) => predicate((u) => u.email.endsWith('@example.com')), cursor: null, limit: 20, maxScan: 500, }); ``` When the scan budget is hit, you'll get a partial page plus `pageStatus` and `splitCursor` hints. `.select().where({ id: { in: [...] } })` pages in the order the ids are given and reads only the listed positions that page visits. Missing or policy-filtered ids still count toward `maxScan`. Adding `orderBy` switches the read to a scan, so the order has to be one a scan produces: a single field that an index leads with, with no `withIndex(...)` pinned. `orderBy: { createdAt }` reads every id in the list to sort it, so it cannot be capped at all. `allowFullScan` does not apply in cursor mode. If cursor pagination needs a scan-fallback plan and: * `strict: true`: missing `maxScan` throws. * `strict: false`: missing `maxScan` warns and runs uncapped. Composition Pagination (Advanced) [#composition-pagination-advanced] `select()` composition is the public way to do stream-style transforms before pagination. Union/interleave (merged-stream behavior) [#unioninterleave-merged-stream-behavior] `select().union(...).interleaveBy(...)` is the merged-stream equivalent. ```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']) .paginate({ cursor: null, limit: 20 }); ``` Each source takes `index` and `where`: * `index: { name, range }` anchors that source on its own index range. It overrides `.withIndex(...)` on the chain, and sources that omit it fall back to the chain index. * `where` is compiled against the table's indexes like a `findMany` where. It bounds the read when it can be lowered onto the index that source walks; whatever no index covers is filtered after the read. `interleaveBy` fields must be the trailing fields each source is already ordered by — everything before them has to be pinned with `eq` in that source's range. Sources may name different indexes as long as they land on the same trailing fields: ```ts showLineNumbers // by_author_likes is (authorId, numLikes); numLikesAndType is (type, numLikes). // Pinning the leading field on each leaves both ordered by numLikes. const page = await db.query.posts .select() .union([ { index: { name: 'by_author_likes', range: (q) => q.eq('authorId', me) }, }, { index: { name: 'numLikesAndType', range: (q) => q.eq('type', 'text') }, }, ]) .interleaveBy(['numLikes']) .paginate({ cursor: null, limit: 20 }); ``` Whether a source `where` bounds the read depends on the index that source ends up walking: * **No index pinned** — the `where` picks one. `{ where: { status: 'active' } }` reads the `by_status` range. * **A pinned index the `where` can narrow** — `.withIndex('by_status')` plus `{ where: { status: 'active' } }` narrows that same index to the matching range. * **A pinned index the `where` cannot narrow** — `.withIndex('by_name')` plus `{ where: { status: 'active' } }` walks `by_name` and drops the misses after reading them. A pinned `range` is the source's scope, never a hint: a `where` alongside one always filters after the read rather than widening it. Lowering also has to preserve the merge, so a `where` is only lowered when the resulting order can still supply `interleaveBy`. Anchor a source with `index: { name, range }` whenever you need a specific range read. Pre-pagination transforms [#pre-pagination-transforms] You can apply transforms in order by chaining: ```ts showLineNumbers const page = await db.query.messages .select() .filter(async (m) => !m.deletedAt) .map(async (m) => ({ ...m, preview: m.body.slice(0, 120) })) .distinct({ fields: ['channelId'] }) .paginate({ cursor: null, limit: 20, maxScan: 500 }); ``` Relation-targeted flatMap [#relation-targeted-flatmap] ```ts showLineNumbers const page = await db.query.users .select() .flatMap('posts', { includeParent: true }) .paginate({ cursor: null, limit: 20 }); ``` See [API Reference -- Composition Limitations](#composition-limitations) for a full list of unsupported `select()` combinations. Key-Based Paging (pageByKey) [#key-based-paging-pagebykey] Use `pageByKey` when you want explicit key boundaries rather than opaque cursors. ```ts showLineNumbers const first = await db.query.messages.findMany({ pageByKey: { index: 'by_channel', order: 'asc', targetMaxRows: 100, }, }); const second = await db.query.messages.findMany({ pageByKey: { index: 'by_channel', order: 'asc', startKey: first.indexKeys[99], targetMaxRows: 100, }, }); ``` Return shape: ```ts showLineNumbers { page: T[]; indexKeys: (Value | undefined)[][]; hasMore: boolean; } ``` `pageByKey` also supports `endKey`, inclusive/exclusive bounds, and `absoluteMaxRows` for hard page caps. Validation Rules [#validation-rules] These combinations are rejected: * `endCursor` without cursor mode (`cursor + limit`). * `maxScan` without cursor mode. * `maxScan` with `.select().where({ id: { in: [...] } })` and an `orderBy` no scan produces: `createdAt`, more than one field, a field without a leading index, or a pinned `withIndex(...)`. * `pageByKey` with `cursor`, `offset`, `search`, `vectorSearch`, or `select()` composition. Stream-to-ORM Mapping [#stream-to-orm-mapping] Here's how streaming patterns map to ORM equivalents: | Stream pattern | ORM equivalent | | ------------------------------------------------------ | ------------------------------------------------------------------------ | | `db.stream().query(...).filterWith(...).paginate(...)` | `select().filter(...).paginate({ cursor, limit })` | | `mergedStream([...], fields)` | `select().union([...]).interleaveBy(fields).paginate({ cursor, limit })` | | manual `map`/`flatMap` stream chains | `select().map(...).flatMap(...).paginate({ cursor, limit })` | | `getPage(...)` key-boundary paging | `findMany({ pageByKey: { ... } })` | | `paginator(...).query(...).paginate(...)` | `findMany({ cursor, limit, ... })` | }> Keep the query shape stable between calls when paginating. Changing indexes/order/filters between page requests invalidates cursor continuity. API Reference [#api-reference] Composition Limitations [#composition-limitations] The following `select()` combinations are not supported: | Combination | Status | | ------------------------- | --------------------- | | Composition entrypoint | `select()` chain only | | `select() + search` | Not supported | | `select() + vectorSearch` | Not supported | | `select() + offset` | Not supported | | `select() + with` | Not supported | | `select() + extras` | Not supported | | `select() + columns` | Not supported | # Columns (/docs/orm/schema/column-types) import { InfoIcon, AlertTriangle } from "lucide-react" }> The ORM documents a **Convex subset** of Drizzle's PostgreSQL column types. Temporal fields are supported with `date()` and `timestamp()`. For nested document arrays/objects, use `arrayOf()` / `objectOf()`. Use `custom()` / `json()` for advanced validator escape hatches. Temporal Semantics [#temporal-semantics] Use temporal builders intentionally: * `timestamp()` is for point-in-time values (`createdAt`, `updatedAt`, `expiresAt`). * `date()` is for calendar dates (`YYYY-MM-DD`) with no time component. * In ORM APIs, `createdAt` is the public field name. Convex internals (`_creationTime`) stay internal. For most tables, use: ```ts showLineNumbers createdAt: timestamp().notNull().defaultNow() ``` This gives `createdAt: Date` in ORM types while keeping Convex storage numeric. Column Names [#column-names] In Drizzle, you can optionally provide a database column name (`text('email')`) that can differ from the TypeScript key. In the ORM, the **TypeScript key is the document field name**. ```ts showLineNumbers import { convexTable, text } from 'kitcn/orm'; export const users = convexTable('users', { email: text().notNull(), }); ``` }> **Note:** Column builders still accept an optional `name` parameter for Drizzle parity, but it's usually unnecessary in Convex schemas. Type Rules (Select vs Insert) [#type-rules-select-vs-insert] The ORM mirrors Drizzle's core inference rules: * **Select** (`Select<'table'>`, `$inferSelect`, `InferSelectModel`): fields are `T | null` unless `.notNull()` is set * **Insert** (`Insert<'table'>`, `$inferInsert`, `InferInsertModel`): required if `.notNull()` and **no** `.default()`, `$defaultFn()`, or `$onUpdateFn()`, otherwise optional ```ts showLineNumbers {3-5} import { convexTable, text, integer, InferInsertModel, InferSelectModel } from 'kitcn/orm'; const posts = convexTable('posts', { title: text().notNull(), status: text().notNull().default('draft'), viewCount: integer(), }); const tables = { posts }; type TableName = keyof typeof tables; type Select = InferSelectModel<(typeof tables)[T]>; type Insert = InferInsertModel<(typeof tables)[T]>; type Post = Select<'posts'>; // ^? { id, createdAt, title: string, status: string, viewCount: number | null } type NewPost = Insert<'posts'>; // ^? { title: string; status?: string | undefined; viewCount?: number | undefined } ``` }> **Tip:** Fields are nullable by default. Use `.notNull()` to make a field required on select, and add `.default()` to make it optional on insert. Column Hooks (Defaults and Type Overrides) [#column-hooks-defaults-and-type-overrides] The ORM supports Drizzle-style column hooks for common patterns like timestamps and JSON blobs. ```ts showLineNumbers {4-6} import { convexTable, json, text, timestamp } from 'kitcn/orm'; const users = convexTable('users', { email: text().notNull(), createdAt: timestamp().notNull().defaultNow(), updatedAt: timestamp().notNull().$onUpdateFn(() => new Date()), metadata: json>(), }); ``` The available hooks are: * `$type()` -- a type-only override (no runtime validation) * `$defaultFn(fn)` / `$default(fn)` -- runs on insert when the value is omitted * `$onUpdateFn(fn)` / `$onUpdate(fn)` -- runs on update when the field is not explicitly set (and can also fill missing values on insert) }> **Important:** These hooks run only through ORM mutations. Direct `ctx.db` writes bypass them. Builder Reference [#builder-reference] text() [#text] Represents a string field. ```ts showLineNumbers import { convexTable, text } from 'kitcn/orm'; const users = convexTable('users', { name: text().notNull(), // string bio: text(), // string | null }); ``` textEnum(values) [#textenumvalues] Defines an enum-like string field, validated at runtime and inferred as a string union in TypeScript. ```ts showLineNumbers import { convexTable, textEnum } from 'kitcn/orm'; const users = convexTable('users', { role: textEnum(['admin', 'member'] as const).notNull(), }); ``` integer() [#integer] Represents a numeric field. In Convex, `integer()` maps to `v.number()` (Float64). ```ts showLineNumbers import { convexTable, integer } from 'kitcn/orm'; const events = convexTable('events', { retries: integer().notNull(), score: integer(), }); ``` date() [#date] Calendar date column (`YYYY-MM-DD` semantics). ```ts showLineNumbers import { convexTable, date } from 'kitcn/orm'; const users = convexTable('users', { birthday: date(), // string | null (default mode) anniversary: date({ mode: 'date' }), // Date | null }); ``` timestamp() [#timestamp] Point-in-time column for event and audit timestamps. ```ts showLineNumbers import { convexTable, timestamp } from 'kitcn/orm'; const users = convexTable('users', { createdAt: timestamp().notNull().defaultNow(), // Date startedAtText: timestamp({ mode: 'string' }), // string | null }); ``` boolean() [#boolean] Represents a boolean field. ```ts showLineNumbers import { convexTable, boolean } from 'kitcn/orm'; const flags = convexTable('flags', { enabled: boolean().notNull(), beta: boolean(), }); ``` bigint() [#bigint] Represents a 64-bit integer field. In Convex, `bigint()` maps to `v.int64()` and uses JavaScript `bigint`. ```ts showLineNumbers import { convexTable, bigint } from 'kitcn/orm'; const ledger = convexTable('ledger', { balance: bigint().notNull(), }); ``` bytes() [#bytes] Represents a binary field (`ArrayBuffer`). ```ts showLineNumbers import { bytes, convexTable } from 'kitcn/orm'; const files = convexTable('files', { data: bytes().notNull(), }); ``` id('table') [#idtable] Represents a reference to another table (`Id<'table'>`). ```ts showLineNumbers import { convexTable, id, text } from 'kitcn/orm'; const posts = convexTable('posts', { title: text().notNull(), authorId: id('users').notNull(), }); ``` }> **Note:** `id('users')` is a typed reference. It does **not** create a SQL foreign key constraint. Use `.references()` or `foreignKey()` for ORM-enforced foreign keys. vector(dimensions) [#vectordimensions] Represents an embedding vector (`number[]`) and is used with `vectorIndex()`. ```ts showLineNumbers import { convexTable, vector } from 'kitcn/orm'; const posts = convexTable('posts', { embedding: vector(1536).notNull(), }); ``` discriminator(config) [#discriminatorconfig] Defines a schema-first discriminated union column. The discriminator field stores the variant key as a string, and each variant's fields are generated as nullable top-level columns. On read, variant fields are grouped into a nested object at the alias (default `"details"`). ```ts showLineNumbers import { boolean, convexTable, discriminator, id, index, integer, text } from 'kitcn/orm'; const auditLogs = convexTable( 'audit_logs', { timestamp: integer().notNull(), actionType: discriminator({ as: 'details', variants: { role_change: { targetUserId: id('users'), oldRole: text().notNull(), newRole: text().notNull(), }, security_alert: { severity: text().notNull(), isResolved: boolean().notNull(), }, }, }), }, (t) => [ index('by_action_ts').on(t.actionType, t.timestamp), index('by_role_target').on(t.actionType, t.targetUserId), ] ); ``` }> **Note:** One `discriminator()` column per table. Variant field `.notNull()` means required in that branch only. Duplicate field names across variants must use identical builder signatures. See [Polymorphic Associations](/docs/orm/schema/relations#polymorphic-associations) for the full guide. arrayOf(element) / objectOf(...) / unionOf(...) [#arrayofelement--objectof--unionof] Use these helpers for nested arrays, nested objects, homogeneous records, and scalar unions without importing `v` directly. ```ts showLineNumbers import { arrayOf, convexTable, integer, objectOf, text, textEnum, unionOf, } from 'kitcn/orm'; const events = convexTable('events', { timeline: arrayOf( objectOf({ timestamp: integer().notNull(), type: textEnum(['status', 'milestone', 'note'] as const).notNull(), content: text().notNull(), addedBy: text().notNull(), }) ).notNull(), variables: objectOf( unionOf(text().notNull(), integer().notNull()).notNull() ).notNull(), }); ``` custom(validator) (advanced) [#customvalidator-advanced] Use `custom()` to wrap any Convex validator directly when you need an escape hatch. ```ts showLineNumbers import { convexTable, custom } from 'kitcn/orm'; import { v } from 'convex/values'; const events = convexTable('events', { payload: custom( v.object({ type: v.string(), data: v.any(), }) ), }); ``` json() [#jsont] `json()` is a convenience wrapper around `custom(v.any()).$type()`. ```ts showLineNumbers import { convexTable, json } from 'kitcn/orm'; const posts = convexTable('posts', { metadata: json<{ tags: string[] }>(), }); ``` }> **Note:** `json()` is **not** SQL `jsonb`. It's a Convex `v.any()` value with a TypeScript type annotation. Not Supported [#not-supported] These Drizzle (SQL) column type categories don't map to Convex documents and are not supported: * SQL database-side timezone/precision semantics for timestamp/date types * SQL decimals/numerics * SQL database enums (use `textEnum(...)`) * SQL arrays (`arrayOf(...)` models document arrays, not SQL array columns) * Custom SQL types Next Steps [#next-steps] # Schema (/docs/orm/schema) import { InfoIcon, AlertTriangle } from "lucide-react" Table Definition [#table-definition] Define tables using `convexTable()` with Drizzle-style column builders: ```ts title="convex/schema.ts" showLineNumbers {3-7} import { convexTable, text, integer, id } from 'kitcn/orm'; export const users = convexTable('users', { name: text().notNull(), email: text().notNull(), role: text(), age: integer(), }); ``` Convex auto-generates two fields on every document: * `id: Id<'table'>` * `createdAt: number` (default) If you want Date values, define `createdAt: timestamp().notNull().defaultNow()` in your table schema. System `createdAt` stays numeric when no explicit column exists. Temporal Field Model (createdAt, timestamp(), date()) [#temporal-field-model-createdat-timestamp-date] Use these rules consistently: * Use `timestamp()` for point-in-time values (`createdAt`, `updatedAt`, `expiresAt`, etc.). * Use `date()` for calendar-only values (no time-of-day semantics). * In ORM APIs, `createdAt` is always the public key; `_creationTime` is internal. | Schema shape | ORM read type for `createdAt` | Convex storage | | ----------------------------------------------- | ------------------------------- | ----------------------- | | No explicit `createdAt` column | `number` (system alias) | `_creationTime: number` | | `createdAt: timestamp().notNull().defaultNow()` | `Date` (default timestamp mode) | `createdAt: number` | | `createdAt: timestamp({ mode: 'string' })` | `string` | `createdAt: number` | If you use cRPC, Date serialization is built in, so timestamp-backed `Date` values cross the wire safely. Nullability [#nullability] Fields are nullable by default. Use `.notNull()` to make a field required: ```ts showLineNumbers const users = convexTable('users', { name: text().notNull(), // string bio: text(), // string | null }); ``` Table-Level Delete Defaults [#table-level-delete-defaults] You can configure default delete semantics per table with `deletion(...)`: ```ts showLineNumbers {7} import { convexTable, deletion, integer, text } from 'kitcn/orm'; const users = convexTable( 'users', { slug: text().notNull(), deletionTime: integer(), }, () => [deletion('scheduled', { delayMs: 60_000 })] ); ``` Available modes: * `deletion('hard')` -- permanent delete * `deletion('soft')` -- mark as deleted, keep data * `deletion('scheduled', { delayMs? })` -- soft delete, then hard delete after delay See [Column Types](/docs/orm/schema/column-types) for the full ORM builder reference and Drizzle mapping notes. Relations [#relations] Use schema-native relations in `defineSchema(...).relations(...)` so codegen reads metadata from the default export: ```ts title="convex/schema.ts" showLineNumbers {5-10} import { defineSchema, } from 'kitcn/orm'; export const posts = convexTable('posts', { title: text().notNull(), userId: id('users'), }); 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 }), }, })); ``` Plugin relation composition follows the same model: * Plugins can expose a `relations` map. * `defineSchema` merges plugin relations first, then app `relations`. * Duplicate relation fields (`table.field`) throw. Schema Options [#schema-options] `defineSchema` accepts Convex schema options plus ORM runtime guardrail options: ```ts showLineNumbers import { defineSchema } from 'kitcn/orm'; export default defineSchema({ users, posts }, { strict: false }); ``` The `strict` flag controls how aggressively the ORM enforces index usage: * `strict` (default `true`): relation index misses throw unless `allowFullScan: true`; predicate `where` requires explicit `.withIndex(...)`. * `strict: false`: relation index misses still require `allowFullScan`; predicate `where` still requires `.withIndex(...)`. Runtime Defaults [#runtime-defaults] You can set global runtime guardrails via the `defaults` option. These control limits for queries, relation loading, and mutation batching. For the full list of options and their built-in fallback values, see [API Reference](#runtime-defaults) below. Strict Policy (Guardrails) [#strict-policy-guardrails] Strict guardrails cover relation indexes, relation fan-out cardinality, predicate `where()`, and unbounded `update`/`delete`. For the full behavior matrix, see [API Reference](#strict-policy-guardrails) below. Indexes [#indexes] Add indexes for fields you filter or sort by: ```ts showLineNumbers {7} import { convexTable, text, id, index } from 'kitcn/orm'; const posts = convexTable( 'posts', { title: text().notNull(), authorId: id('users'), }, (t) => [index('by_author').on(t.authorId)] ); ``` Search Indexes [#search-indexes] For full-text search, use `searchIndex`. You can optionally filter results by additional fields: ```ts showLineNumbers {9} import { convexTable, text, id, searchIndex } from 'kitcn/orm'; const posts = convexTable( 'posts', { title: text().notNull(), authorId: id('users'), }, (t) => [searchIndex('by_text').on(t.title).filter(t.authorId)] ); ``` Vector Indexes [#vector-indexes] For vector similarity search, use `vectorIndex` with a `vector(dimensions)` field: ```ts showLineNumbers {9-13} import { convexTable, id, 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), ] ); ``` Use `vector(dimensions)` for embedding fields. }> **Important:** Missing `.on(...)` throws "Did you forget to call .on(...)?" and missing `.dimensions(n)` on vector indexes throws "missing dimensions". Constraints And Defaults [#constraints-and-defaults] * Defaults set with `.default(...)` and `$defaultFn(...)` are applied by ORM inserts * `$onUpdateFn(...)` columns are applied on updates (and can also fill missing values on insert) * `unique()` and column `.unique()` are enforced at runtime (ORM mutations only) * `foreignKey()` and column `.references()` are enforced at runtime (ORM mutations only) * `check()` constraints are enforced at runtime (ORM mutations only) }> **Important:** Constraints and defaults are enforced only by ORM mutations. Direct `ctx.db` writes bypass these checks. For foreign key actions (`cascade`, `restrict`, `set null`, `set default`), add an index on the referencing columns. Common Gotchas [#common-gotchas] | Issue | Fix | | ---------------------------- | -------------------------------------------------------------- | | Defining `id` manually | Convex creates `id` automatically | | Fields unexpectedly nullable | Use `.notNull()` | | Relations not loading | Define both sides or use explicit `from`/`to` | | Slow ordering | Add `index('...').on(t.field)` for the primary `orderBy` field | API Reference [#api-reference] Runtime Defaults [#runtime-defaults-1] You can set global runtime guardrails via the `defaults` option passed to `defineSchema`. These control limits for queries, relation loading, and mutation batching: ```ts showLineNumbers {3-13} export default defineSchema({ users, posts }, { strict: true, defaults: { defaultLimit: 1000, // non-paginated findMany() fallback relationFanOutMaxKeys: 1000, // max unique relation lookup keys per relation load mutationBatchSize: 400, // update/delete batch size mutationLeafBatchSize: 1600, // async non-recursive FK fan-out batch size mutationMaxRows: 10000, // sync-mode hard cap mutationMaxBytesPerBatch: 2_097_152, // async measured-byte budget mutationScheduleCallCap: 800, // async schedule calls allowed per mutation mutationExecutionMode: 'async', // or 'sync' mutationAsyncDelayMs: 0, // scheduler delay between async batches }, }); ``` * `defaults.defaultLimit` -- used when `findMany()` is non-paginated and `limit` is omitted. No implicit built-in fallback is applied when this is unset. * `defaults.relationFanOutMaxKeys` -- maximum unique relation lookup keys allowed per relation-load step before fail-fast. Built-in fallback: `1000`. * `defaults.mutationBatchSize` -- page size used to collect rows for `update()`/`delete()`. Built-in fallback: `400`. * `defaults.mutationLeafBatchSize` -- async batch size for non-recursive FK fan-out actions (`set null`, `set default`, non-recursive update fan-out). Built-in fallback: `1600`. * `defaults.mutationMaxRows` -- sync-mode hard cap for matched rows in `update()`/`delete()`. Built-in fallback: `10000`. * `defaults.mutationMaxBytesPerBatch` -- async measured-byte budget per continuation batch. Built-in fallback: `2_097_152` bytes. * `defaults.mutationScheduleCallCap` -- async schedule calls allowed from one mutation invocation before throwing. Built-in fallback: `800`. * `defaults.mutationExecutionMode` -- default execution mode for non-paginated `execute()` calls (`'sync'` or `'async'`). `'async'` requires scheduling wiring (`scheduler` + `scheduledMutationBatch`); without wiring, default-mode execution falls back to `'sync'`. Built-in fallback is `'async'` when scheduling is wired, otherwise `'sync'`. * `defaults.mutationAsyncDelayMs` -- default scheduler delay used by async mutation continuation. Built-in fallback: `0`. }> **Note:** Numeric defaults must be integers. `mutationAsyncDelayMs` allows `0`; all others must be `>= 1`. Strict Policy (Guardrails) [#strict-policy-guardrails-1] Strict guardrails cover relation indexes, relation fan-out cardinality, predicate `where()`, and unbounded `update`/`delete`: | Area | Current (2026-02-06) | Strict `true` | Strict `false` | | ----------------------------------------------------- | ------------------------------------------------------------------------------------------- | --------------------------------------------------- | --------------------------------------------------- | | Relation loading missing index | throws unless `allowFullScan` | throws unless `allowFullScan`; warns when scanning | throws unless `allowFullScan`; no strict warning | | Predicate `where` | requires explicit `.withIndex(name, range?)` (no `allowFullScan` fallback) | same | same | | Object/callback expression `where` not index-compiled | allowed as bounded scan (subject to sizing rules) | same | same | | Pagination orderBy missing index | strict `true` throws; strict `false` warns + falls back to `createdAt` | throws (add index or disable strict) | warns + falls back to `createdAt` | | Unsized non-paginated `findMany()` | throws unless `limit`/cursor pagination (`cursor` + `limit`)/`defaultLimit`/`allowFullScan` | same | same | | `many()` relation without per-parent limit | throws unless relation `limit`/`defaultLimit`/`allowFullScan` | same | same | | Relation lookup fan-out keys exceed cap | throws unless `allowFullScan` (`defaults.relationFanOutMaxKeys`, default `1000`) | throws unless `allowFullScan`; warns when scanning | throws unless `allowFullScan`; no strict warning | | Update/delete without `where()` | throws unless `allowFullScan` | throws unless `allowFullScan`; warns when scanning | throws unless `allowFullScan`; no strict warning | | Update/delete row count exceeds `mutationMaxRows` | sync mode throws; async mode schedules continuation | sync mode throws; async mode schedules continuation | sync mode throws; async mode schedules continuation | Next Steps [#next-steps] Builders and TypeScript types Indexes, unique constraints, and foreign keys One-to-one, one-to-many, many-to-many Filters, ordering, pagination Full API surface Differences and tradeoffs # Indexes (/docs/orm/schema/indexes-constraints) import { InfoIcon, AlertTriangle } from "lucide-react" The ORM mirrors Drizzle's schema API where it makes sense, but enforces constraints **at runtime** (in ORM mutations), not inside a SQL database. }> **Important:** Constraints are enforced **only** by ORM mutations (`insert/update/delete`). Direct `ctx.db` writes bypass them. Constraints [#constraints] Default [#default] Use `.default(value)` to define a default for inserts. Defaults are applied by ORM inserts when the value is `undefined`. Explicit `null` is preserved. ```ts showLineNumbers {4} import { convexTable, integer, text } from 'kitcn/orm'; export const posts = convexTable('posts', { title: text().notNull(), viewCount: integer().default(0), }); ``` Not Null [#not-null] Fields are nullable by default. Use `.notNull()` to require a value. ```ts showLineNumbers {4-5} import { convexTable, text } from 'kitcn/orm'; export const users = convexTable('users', { email: text().notNull(), bio: text(), }); ``` Unique [#unique] You can model uniqueness in a few Drizzle-style ways. uniqueIndex() [#uniqueindex] ```ts showLineNumbers {6} import { convexTable, text, uniqueIndex } from 'kitcn/orm'; export const users = convexTable( 'users', { email: text().notNull(), }, (t) => [uniqueIndex('users_email_unique').on(t.email)] ); ``` unique() [#unique-1] For compound uniqueness, use `unique('name').on(...)` with multiple columns: ```ts showLineNumbers {8} import { convexTable, text, unique } from 'kitcn/orm'; export const users = convexTable( 'users', { firstName: text().notNull(), lastName: text().notNull(), }, (t) => [unique('full_name').on(t.firstName, t.lastName)] ); ``` You can use `.nullsNotDistinct()` for PostgreSQL parity when you want `null` to be treated as a value. }> **Important:** Uniqueness is enforced at runtime and is **best-effort** within a mutation. Concurrent mutations can still race. For strict guarantees, serialize writes per key or use `onConflictDoNothing()` / retries. Column .unique() [#column-unique] For single-column uniqueness, you can call `.unique()` directly on the column builder: ```ts showLineNumbers {4} import { convexTable, text } from 'kitcn/orm'; export const users = convexTable('users', { email: text().notNull().unique(), }); ``` Foreign Keys [#foreign-keys] Foreign keys are enforced at runtime by the ORM mutation builders. Column .references() [#column-references] ```ts showLineNumbers {8} import { convexTable, id, text } from 'kitcn/orm'; export const users = convexTable('users', { email: text().notNull(), }); export const posts = convexTable('posts', { title: text().notNull(), authorId: id('users').notNull().references(() => users.id), }); ``` foreignKey() [#foreignkey] For referencing non-`id` columns, use the `foreignKey()` builder: ```ts showLineNumbers {9} import { convexTable, foreignKey, text } from 'kitcn/orm'; export const users = convexTable('users', { slug: text().notNull().unique(), }); export const memberships = convexTable( 'memberships', { userSlug: text().notNull(), }, (t) => [ foreignKey({ columns: [t.userSlug], foreignColumns: [users.slug] }), ] ); ``` Self-Referencing Foreign Keys [#self-referencing-foreign-keys] When a table references itself (e.g. a tree of comments), annotate the callback return type with `AnyColumn` to avoid circular-reference errors: ```ts showLineNumbers {1,6-8} import { type AnyColumn, convexTable, text } from 'kitcn/orm'; export const comments = convexTable('comments', { body: text().notNull(), parentId: text().references((): AnyColumn => comments.id, { onDelete: 'cascade', }), }); ``` Actions (onDelete / onUpdate) [#actions-ondelete--onupdate] The ORM supports Drizzle-style foreign key actions, enforced at runtime by ORM mutations. ```ts showLineNumbers {5-7} export const posts = convexTable( 'posts', { title: text().notNull(), authorId: id('users').notNull().references(() => users.id, { onDelete: 'cascade', }), }, (t) => [index('by_author').on(t.authorId)] ); ``` }> **Tip:** For non-`id` foreign keys, add an index on the referenced columns (parent table) so inserts/updates can validate quickly. For `cascade` / `set null` / `set default`, add an index on the referencing columns (child table). Without an index, the ORM will throw if it detects referencing rows. Async soft cascade requires an exact child index whose declared fields are the referencing columns. For example, `index('by_author').on(t.authorId)` qualifies; `index('by_author_rank').on(t.authorId, t.rank)` does not. Mutable trailing fields cannot provide stable continuation across scheduled mutations. Check Constraints [#check-constraints] Use `check(name, expression)` to enforce invariants on inserts and updates. ```ts showLineNumbers {8-9} import { check, convexTable, gt, integer, isNotNull, text } from 'kitcn/orm'; export const users = convexTable( 'users', { age: integer(), email: text(), }, (t) => [ check('age_over_18', gt(t.age, 18)), check('email_present', isNotNull(t.email)), ] ); ``` }> **Note:** Checks follow SQL-like NULL semantics: they fail only when the expression evaluates to `false`. If it evaluates to `unknown` (because of `null`/`undefined`), it passes. Use `.notNull()` or `isNotNull(...)` for strict presence. Indexes [#indexes] }> Do not create indexes on `createdAt`. In kitcn ORM, `createdAt` maps to internal `_creationTime`, and explicit `createdAt` indexes can fail. Use `updatedAt` (or another explicit sortable field) for custom indexes. index() [#index] Add indexes for fields you filter or sort by. ```ts showLineNumbers {8} import { convexTable, id, index, text } from 'kitcn/orm'; export const posts = convexTable( 'posts', { title: text().notNull(), authorId: id('users').notNull(), }, (t) => [index('by_author').on(t.authorId)] ); ``` searchIndex() [#searchindex] Search indexes mirror Convex's text search, with a Drizzle-style builder API. ```ts showLineNumbers {8} import { convexTable, id, searchIndex, text } from 'kitcn/orm'; export const posts = convexTable( 'posts', { title: text().notNull(), authorId: id('users').notNull(), }, (t) => [searchIndex('by_title').on(t.title).filter(t.authorId)] ); ``` }> **Tip:** Use `.staged()` to stage an index for later activation. vectorIndex() [#vectorindex] Vector indexes mirror Convex's vector search API. ```ts showLineNumbers {8-11} import { convexTable, id, vector, vectorIndex } from 'kitcn/orm'; export const posts = convexTable( 'posts', { authorId: id('users').notNull(), embedding: vector(1536).notNull(), }, (t) => [ vectorIndex('embedding_vec') .on(t.embedding) .dimensions(1536) .filter(t.authorId), ] ); ``` Drizzle Differences [#drizzle-differences] A few things work differently from SQL-based Drizzle: * Convex creates `id` automatically (no `primaryKey()` builder) * Check constraints are enforced at runtime (ORM mutations only) * SQL-only features (database-side cascades, triggers, raw SQL) are not applicable Next Steps [#next-steps] # Relations (/docs/orm/schema/relations) import { InfoIcon, AlertTriangle } from "lucide-react" Overview [#overview] Relations in the ORM use the same API as Drizzle for **defining** and **loading** relationships. The `with:` option is type-safe, with a few nested constraints noted below. Relation loading via `with:` works end-to-end. Nested `with` loads up to 10 levels deep, and per-relation filters for `many()` relations are applied post-fetch. Relation loading requires indexes on relation fields (e.g. `posts.userId`). Missing indexes throw unless you opt in with `allowFullScan`. You can use `defineSchema(..., { strict: false })` to downgrade throws to warnings when `allowFullScan` is set. Relations are about **loading** related rows. They do **not** enforce referential integrity. If you want runtime checks / cascades, define foreign keys separately with `.references()` / `foreignKey()` (see [/docs/orm/indexes-constraints](/docs/orm/indexes-constraints)). }> Loading `many()` relations requires explicit sizing: provide `with..limit`, a schema `defaults.defaultLimit`, or `allowFullScan` on the parent query. See Schema Options for strict policy details. Relation Config Options [#relation-config-options] The callback shape mirrors Drizzle's relation builder, but in kitcn you attach relations directly to the default schema export with `.relations(...)`. one() [#one] ```ts showLineNumbers r.one.users({ from: r.posts.authorId, to: r.users.id, optional: false, alias: 'author', where: { isActive: true }, }) ``` * `from` / `to`: describe the relationship using columns * `optional`: type-level nullability (`true` by default) * `alias`: disambiguate multiple relations between the same tables * `where`: conditional relation loading (filter which related rows are loaded) many() [#many] ```ts showLineNumbers r.many.posts({ from: r.users.id, to: r.posts.authorId, alias: 'posts', where: { published: true }, }) ``` * `from` / `to`: optional when the inverse relation is unambiguous * `alias`: disambiguate multiple relations between the same tables * `where`: filter which related rows are loaded Disambiguating Relations With alias [#disambiguating-relations-with-alias] If you have **multiple relations between the same two tables**, you must use `alias` to tell the ORM which relation is which. Classic example: a post has both an `author` and a `reviewer`, pointing at the same `users` table. ```ts title="convex/schema.ts" showLineNumbers import { convexTable, defineSchema, id, text } from 'kitcn/orm'; const users = convexTable('users', { name: text().notNull(), }); const posts = convexTable('posts', { title: text().notNull(), authorId: id('users').notNull(), reviewerId: id('users'), }); export default defineSchema({ users, posts }).relations((r) => ({ users: { // No need for from/to here: these infer from the reverse one() relations. authoredPosts: r.many.posts({ alias: 'author' }), reviewedPosts: r.many.posts({ alias: 'reviewer' }), }, posts: { author: r.one.users({ from: r.posts.authorId, to: r.users.id, alias: 'author', }), reviewer: r.one.users({ from: r.posts.reviewerId, to: r.users.id, alias: 'reviewer', }), }, })); ``` `alias` does not change your output keys (`author`, `reviewer`, `authoredPosts`, `reviewedPosts`). It only disambiguates the underlying edge metadata. Relation Types [#relation-types] One-to-One Relations [#one-to-one-relations] A one-to-one relation connects exactly one record in each table: ```ts title="convex/schema.ts" showLineNumbers {12-21} import { convexTable, defineSchema, text, id } from 'kitcn/orm'; const users = convexTable('users', { name: text().notNull(), }); const profiles = convexTable('profiles', { bio: text().notNull(), userId: id('users'), }); export default defineSchema({ users, profiles }).relations((r) => ({ profiles: { user: r.one.users({ from: r.profiles.userId, to: r.users.id, }), }, users: { profile: r.one.profiles(), }, })); ``` }> **Note**: Use `from` and `to` to describe the foreign key relationship. In Convex, primary keys are `id`. }> **Important**: Enforce a unique constraint on the foreign key column (`.unique()` / `uniqueIndex()`) for true 1:1 semantics. Runtime uniqueness is best-effort; concurrent mutations can still race. One-to-Many Relations [#one-to-many-relations] The most common relation type - one user has many posts: ```ts title="convex/schema.ts" showLineNumbers {12-20} import { convexTable, defineSchema, text, boolean, id } from 'kitcn/orm'; const users = convexTable('users', { name: text().notNull(), }); const posts = convexTable('posts', { title: text().notNull(), content: text().notNull(), published: boolean(), userId: id('users'), }); 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, }), }, })); ``` **Key Points:** * **Foreign key is on the "many" side**: `posts.userId` references `users.id` * **Define both sides**: Define relations on both users and posts * **Export both**: Both table and relations object must be in schema export Many-to-Many Relations (Join Table) [#many-to-many-relations-join-table] The ORM does not auto-create join tables. You model many-to-many with an explicit join table: ```ts title="convex/schema.ts" showLineNumbers {15-24} import { convexTable, defineSchema, text, id } from 'kitcn/orm'; const posts = convexTable('posts', { title: text().notNull(), }); const tags = convexTable('tags', { name: text().notNull(), }); const postsTags = convexTable('postsTags', { postId: id('posts'), tagId: id('tags'), }); export default defineSchema({ posts, tags, postsTags }).relations((r) => ({ posts: { tags: r.many.postsTags(), }, tags: { posts: r.many.postsTags(), }, postsTags: { post: r.one.posts({ from: r.postsTags.postId, to: r.posts.id }), tag: r.one.tags({ from: r.postsTags.tagId, to: r.tags.id }), }, })); ``` Many-to-Many With Data (Join Table + Payload) [#many-to-many-with-data-join-table--payload] If you need extra data on the relationship (role, timestamps, etc.), put it on the join table. No special API required: ```ts title="convex/schema.ts" showLineNumbers {11-16,18-29} import { convexTable, defineSchema, id, integer, text } from 'kitcn/orm'; const users = convexTable('users', { name: text().notNull(), }); const groups = convexTable('groups', { name: text().notNull(), }); const memberships = convexTable('memberships', { userId: id('users').notNull(), groupId: id('groups').notNull(), role: text().notNull(), joinedAt: integer().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 }), }, })); ``` You can then load the join table payload alongside nested relations: ```ts title="convex/queries.ts" showLineNumbers {3-6} const usersWithGroups = await db.query.users.findMany({ with: { memberships: { columns: { role: true, joinedAt: true }, with: { group: true }, }, }, }); ``` Many-to-Many Relations with .through() [#many-to-many-relations-with-through] You can also define direct many-to-many relations with `.through()` to point at junction table columns: ```ts title="convex/schema.ts" showLineNumbers {16-27} import { boolean, convexTable, defineSchema, id, text } from 'kitcn/orm'; const users = convexTable('users', { name: text().notNull(), }); const groups = convexTable('groups', { name: text().notNull(), }); const usersToGroups = convexTable('usersToGroups', { userId: id('users').notNull(), groupId: id('groups').notNull(), }); export default defineSchema({ users, groups, usersToGroups }).relations((r) => ({ users: { groups: r.many.groups({ from: r.users.id.through(r.usersToGroups.userId), to: r.groups.id.through(r.usersToGroups.groupId), alias: 'users-groups-direct', }), }, groups: { users: r.many.users({ from: r.groups.id.through(r.usersToGroups.groupId), to: r.users.id.through(r.usersToGroups.userId), alias: 'users-groups-direct', }), }, })); ``` Self-Referencing Relations [#self-referencing-relations] You can define relations where a table references itself: ```ts title="convex/schema.ts" showLineNumbers {7-17} import { convexTable, defineSchema, text, id } from 'kitcn/orm'; const users = convexTable('users', { name: text().notNull(), username: text().notNull(), managerId: id('users'), }); export default defineSchema({ users }).relations((r) => ({ users: { manager: r.one.users({ from: r.users.managerId, to: r.users.id, alias: 'manager', }), reports: r.many.users({ from: r.users.id, to: r.users.managerId, alias: 'manager', }), }, })); ``` }> **Important**: Use `alias` to distinguish self-referencing relations. Unique names are required to disambiguate. Optional one() Relations [#optional-one-relations] By default, `one()` relations are optional and return `null` when no match is found. Use `optional: false` when the relation is required: ```ts title="convex/schema.ts" showLineNumbers {10-16} import { convexTable, defineSchema, id, text } from 'kitcn/orm'; const users = convexTable('users', { name: text().notNull(), }); const posts = convexTable('posts', { title: text().notNull(), authorId: id('users').notNull(), }); export default defineSchema({ users, posts }).relations((r) => ({ posts: { author: r.one.users({ from: r.posts.authorId, to: r.users.id, optional: false, }), }, })); ``` Relation Definition Rules [#relation-definition-rules] Required Fields [#required-fields] Define the `one()` side with `from` and `to`. The `many()` side can be inferred from the inverse relation: ```ts showLineNumbers {3-6} export default defineSchema({ users, posts }).relations((r) => ({ posts: { author: r.one.users({ from: r.posts.userId, to: r.users.id, }), }, users: { posts: r.many.posts(), }, })); ``` Export Tables and Schema [#export-tables-and-schema] Make sure to export both tables and relations from your schema: ```ts showLineNumbers 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 }) }, })); ``` Split Relations with defineRelationsPart [#split-relations-with-definerelationspart] Use `defineRelationsPart` to split large relation graphs across modules and merge them later: ```ts title="convex/relations.ts" showLineNumbers {4-12,14-21,23-30} import { defineSchema, defineRelationsPart } from 'kitcn/orm'; import { users, posts, comments } from './schema'; const userRelations = defineRelationsPart({ users, posts }, (r) => ({ users: { posts: r.many.posts({ from: r.users.id, to: r.posts.authorId, }), }, })); const postRelations = defineRelationsPart({ posts, comments }, (r) => ({ posts: { comments: r.many.comments({ from: r.posts.id, to: r.comments.postId, }), }, })); export default defineSchema({ users, posts, comments }).relations((r) => ({ users: userRelations.users.relations, posts: { ...userRelations.posts.relations, ...postRelations.posts.relations, }, comments: postRelations.comments.relations, })); ``` }> **Tip**: If a relation relies on inference (no `from`/`to`), define both sides in the same part or use explicit `from`/`to`. Bidirectional Definitions [#bidirectional-definitions] Define both sides when you want `with:` loading in both directions. One-Way Definitions [#one-way-definitions] You can define relations only on the side you need. When you do, provide explicit `from`/`to` (and `alias` if ambiguous). The inverse side is optional and won't be available for `with:` unless you define it. Predefined Filters [#predefined-filters] You can attach a predefined `where` filter to a relation. It will always be applied when loading: ```ts title="convex/schema.ts" showLineNumbers {13-19} import { boolean, convexTable, defineSchema, id, text } from 'kitcn/orm'; const users = convexTable('users', { name: text().notNull(), }); const posts = convexTable('posts', { title: text().notNull(), authorId: id('users'), published: boolean().notNull(), }); export default defineSchema({ users, posts }).relations((r) => ({ users: { publishedPosts: r.many.posts({ from: r.users.id, to: r.posts.authorId, where: { published: true }, alias: 'published-posts', }), }, })); ``` }> `where` in a relation definition can only reference **columns on the target (to) table**. In the ORM, it's applied after fetching candidate rows, so keep `many()` relations intentionally sized. Polymorphic Associations [#polymorphic-associations] Polymorphism is schema-first via a discriminator column builder. Instead of manually wiring nullable FKs, a discriminator, and check constraints, you declare variants directly in the table definition with `discriminator()`. Defining a Polymorphic Table [#defining-a-polymorphic-table] Use one nullable FK per target plus a discriminator. This gives polymorphic-style relation loading and filtered aliases, without strict one-of enforcement. ```ts title="convex/schema.ts" showLineNumbers import { convexTable, defineSchema, id, text } from 'kitcn/orm'; const posts = convexTable('posts', { title: text().notNull(), }); const videos = convexTable('videos', { title: text().notNull(), }); const comments = convexTable('comments', { body: text().notNull(), targetType: text().notNull(), // 'post' | 'video' postId: id('posts'), videoId: id('videos'), }); export default defineSchema({ posts, videos, comments }).relations((r) => ({ posts: { comments: r.many.comments({ from: r.posts.id, to: r.comments.postId, where: { targetType: 'post' }, alias: 'post-comments', }), }, videos: { comments: r.many.comments({ from: r.videos.id, to: r.comments.videoId, where: { targetType: 'video' }, alias: 'video-comments', }), }, })); ``` Step 2: Strict (Schema-Enforced) [#step-2-strict-schema-enforced] Add FK references, indexes, and `check(...)` constraints when a row must point to exactly one target and match the discriminator. We'll use `discriminator()` to define an audit log where each row has an `actionType` discriminator and variant-specific fields: ```ts title="convex/schema.ts" showLineNumbers import { boolean, convexTable, discriminator, id, index, integer, text, } from 'kitcn/orm'; const auditLogs = convexTable( 'audit_logs', { timestamp: integer().notNull(), actionType: discriminator({ as: 'details', // optional alias, defaults to "details" variants: { role_change: { targetUserId: id('users'), oldRole: text().notNull(), newRole: text().notNull(), }, document_update: { documentId: id('documents'), version: integer().notNull(), changes: text().notNull(), }, security_alert: { severity: text().notNull(), errorCode: text().notNull(), isResolved: boolean().notNull(), }, }, }), }, (t) => [ index('by_action_ts').on(t.actionType, t.timestamp), index('by_role_target').on(t.actionType, t.targetUserId), index('by_doc').on(t.actionType, t.documentId), ] ); export default defineSchema({ posts, videos, comments }).relations((r) => ({ posts: { comments: r.many.comments({ from: r.posts.id, to: r.comments.postId, where: { targetType: 'post' }, // optional filtered alias alias: 'post-comments', }), }, videos: { comments: r.many.comments({ from: r.videos.id, to: r.comments.videoId, where: { targetType: 'video' }, // optional filtered alias alias: 'video-comments', }), }, comments: { post: r.one.posts({ from: r.comments.postId, to: r.posts.id, }), video: r.one.videos({ from: r.comments.videoId, to: r.videos.id, }), }, })); ``` **How it works:** * **Storage is flat.** The discriminator (`actionType`) and all variant fields (`targetUserId`, `documentId`, etc.) are stored as top-level document fields. Variant-specific fields are nullable at the storage level. * **Reads synthesize nested data.** When you query, the ORM groups variant fields into a nested `details` object (or your custom `as` alias) as a typed discriminated union. * **Variant fields are available for indexes and filters.** Generated variant columns like `t.targetUserId` and `t.documentId` are normal top-level refs you can use in `index()`, `check()`, and `where`. Querying Polymorphic Tables [#querying-polymorphic-tables] Polymorphism is resolved automatically from the schema. Use `withVariants: true` when you also want automatic variant relation loading: ```ts showLineNumbers const rows = await ctx.orm.query.audit_logs.findMany({ limit: 20, withVariants: true, }); for (const row of rows) { if (row.actionType === 'role_change') { row.details.targetUserId; row.details.oldRole; } else if (row.actionType === 'document_update') { row.details.documentId; row.details.version; } } ``` Writing to Polymorphic Tables [#writing-to-polymorphic-tables] Writes are flat — you set the discriminator and the fields for that variant: ```ts showLineNumbers await ctx.orm.insert(auditLogs).values({ timestamp: Date.now(), actionType: 'role_change', targetUserId: someUserId, oldRole: 'member', newRole: 'admin', }); ``` The ORM validates writes at runtime: required fields for the active variant must be present, and fields from other variants must not be set. Rules [#rules] * One `discriminator()` discriminator column per table (current limit) * Variant keys become the discriminator's literal values * Variant fields are generated as nullable physical columns * `.notNull()` on a variant field means required **in that branch only** * Duplicate field names across variants must have identical builder signatures * The alias (`as`) cannot collide with column names, relation names, `with`, or `extras` }> **Note:** Query config does not include a `polymorphic` option. Polymorphism is defined entirely in schema columns. For the `discriminator()` builder API, see [Column Types -- discriminator()](/docs/orm/schema/column-types#discriminatorconfig). For index requirements per relation type, see [API Reference](#indexing-for-relation-loading) below. Relation Loading with with: [#relation-loading-with-with] The ORM supports Drizzle-style relation loading: ```ts showLineNumbers {3-7} const usersWithPosts = await ctx.orm.query.users.findMany({ with: { posts: { limit: 5, orderBy: { createdAt: 'desc' }, }, }, }); ``` `limit` and `orderBy` are pushed into the relation index when the index already walks in that order — after the foreign key, `index('by_user').on(t.userId)` orders by creation time, so the query above reads five posts per user. Sorting by any other column reads the parent's whole child partition and sorts it in memory; add that column to the relation index (`index('by_user_rank').on(t.userId, t.rank)`) to keep the read bounded. }> **Note**: `limit`, `orderBy`, and `where` apply to `many()` relations. If `limit` is omitted, configure `defineSchema(..., { defaults: { defaultLimit } })` or use `allowFullScan` on the parent query. Relation loading also fail-fasts when unique lookup keys exceed `defineSchema(..., { defaults: { relationFanOutMaxKeys } })` (default `1000`) unless `allowFullScan` is set. Nested `with:` loads every level it is given, up to 10. A tree with rows still to expand past that throws `RELATION_DEPTH_EXCEEDED` instead of coming back shorter than requested — which is how a `with` object that references itself surfaces. Column Selection Semantics [#column-selection-semantics] `columns` follows Drizzle selection rules. You can include specific columns, exclude them, or select none: ```ts title="convex/queries.ts" showLineNumbers {2,6,10-11} const users = await db.query.users.findMany({ columns: { name: true, email: true }, // include-only }); const usersNoEmail = await db.query.users.findMany({ columns: { email: false }, // exclude-only }); const usersOnlyPosts = await db.query.users.findMany({ columns: {}, // no table columns with: { posts: true }, }); ``` }> **Note**: `columns` only affects table columns. Relation data and `extras` are preserved. API Reference [#api-reference] Indexing For Relation Loading [#indexing-for-relation-loading] The ORM will use indexes when loading relations and will throw (unless `allowFullScan`) if the needed index is missing. * For compound relations, your index must start with the same columns in the same order as `to` / `.through(...)`. * `many()` (one-to-many): index the child foreign key (example: `posts.userId`) * `.through()` (many-to-many): index the junction table foreign keys you query by (usually both directions; add a compound index if you also query by both) * `one()`: `to: ...id` uses `db.get()` (no extra index); non-`id` targets require an index on the target fields ```ts title="convex/schema.ts" showLineNumbers {8,18-19} import { convexTable, id, index, text } from 'kitcn/orm'; export const posts = convexTable( 'posts', { title: text().notNull(), userId: id('users').notNull(), }, (t) => [index('by_user').on(t.userId)] ); export const usersToGroups = convexTable( 'usersToGroups', { userId: id('users').notNull(), groupId: id('groups').notNull(), }, (t) => [ index('by_user').on(t.userId), index('by_group').on(t.groupId), index('by_user_group').on(t.userId, t.groupId), ] ); ``` Common Gotchas [#common-gotchas] | Issue | Solution | | ----------------------------------------- | ------------------------------------------------------------------------------------------------------ | | **Relations not working** | Define relations in `defineSchema(...).relations(...)` and verify both sides (or explicit `from`/`to`) | | **Foreign key field missing** | Define the FK column yourself (e.g., `userId: id('users')`) | | **Type errors in `from`/`to`** | Ensure `from` columns belong to the source table and `to` columns belong to the target table | | **Self-referencing conflicts** | Use unique `alias` for each self-referencing relation | | **`with:` not working** | Ensure relations are defined on both tables (or explicit `from`/`to`) | | **`many()` relation throws sizing error** | Add `with..limit`, configure `defaults.defaultLimit`, or set `allowFullScan` | | **Relation fan-out guardrail throws** | Reduce source cardinality, raise `defaults.relationFanOutMaxKeys`, or set `allowFullScan` | Next Steps [#next-steps] Learn findMany(), findFirst(), and filtering Deep dive into table definitions and type inference Understand current limitations See how the ORM maps to Drizzle's API # Triggers (/docs/orm/schema/triggers) import { InfoIcon } from "lucide-react" In this guide, you'll learn how to use triggers — schema-level hooks that run automatically on insert, update, and delete. Triggers let you normalize payloads before writes, fire audit events after writes, update aggregates, and cancel writes based on business rules. Triggers are declared once in `schema.ts` and wired automatically when you chain `.triggers(...)` on the default schema export. Overview [#overview] Use triggers for: * payload normalization before writes * audit/event side effects after writes * aggregate updates via `change` * write cancellation based on business rules Define them on the schema chain with `.triggers({ ... })`. API Shape [#api-shape] ```ts showLineNumbers title="convex/functions/schema.ts" export default defineSchema({ todos }) .relations(() => ({ todos: {}, })) .triggers({ todos: { create: { before: async (data, ctx) => { return { data: { ...data, title: data.title.trim() } }; }, after: async (doc, ctx) => {}, }, update: { before: async (data, ctx) => {}, after: async (doc, ctx) => {}, }, delete: { before: async (doc, ctx) => {}, after: async (doc, ctx) => {}, }, change: async (change, ctx) => {}, }, }); ``` `change` runs for all operations and receives `{ id, operation, oldDoc, newDoc }`. For hook signatures and return types, see [API Reference](#api-reference) below. Setup In Schema [#setup-in-schema] ```ts showLineNumbers title="convex/functions/schema.ts" import { convexTable, defineSchema, text, } from "kitcn/orm"; import type { MutationCtx } from "./generated/server"; import { createUserCaller } from "./generated/user.runtime"; export const userTable = convexTable("user", { name: text().notNull(), email: text().notNull(), }); export default defineSchema({ user: userTable }) .relations(() => ({ user: {}, })) .triggers({ user: { create: { before: async (data) => ({ data: { ...data, email: data.email.toLowerCase() }, }), after: async (doc, ctx) => { const caller = createUserCaller(ctx); await caller.schedule.now.sendWelcomeEmail({ userId: doc.id, }); }, }, change: async (change, ctx) => { await ctx.orm.insert(auditLog).values({ table: "user", operation: change.operation, documentId: change.id, }); }, }, }); ``` }> Trigger definitions are schema-level only. `convexTable(..., extraConfig)` no longer accepts trigger callbacks. Aggregate Integration [#aggregate-integration] `aggregateIndex` and `rankIndex` backfill automatically — no trigger wiring needed for ORM-managed aggregates. Use `change` triggers for custom side effects that need to react to writes: Common Patterns [#common-patterns] Validation + Cancellation [#validation--cancellation] ```ts showLineNumbers title="convex/functions/schema.ts" export default defineSchema({ users }) .relations(() => ({ users: {}, })) .triggers({ users: { create: { before: async (data) => { if (!data.email.includes("@")) return false; }, }, update: { before: async (data) => { if (data.email && !data.email.includes("@")) return false; }, }, }, }); ``` Payload Mutation [#payload-mutation] ```ts showLineNumbers title="convex/functions/schema.ts" export default defineSchema({ users }) .relations(() => ({ users: {}, })) .triggers({ users: { update: { before: async (data) => ({ data: { ...data, updatedAt: Date.now(), }, }), }, }, }); ``` Change Auditing [#change-auditing] ```ts showLineNumbers title="convex/functions/schema.ts" export default defineSchema({ teams }) .relations(() => ({ teams: {}, })) .triggers({ teams: { change: async (change, ctx) => { await ctx.orm.insert(auditLog).values({ table: "teams", operation: change.operation, documentId: change.id, oldDoc: change.oldDoc, newDoc: change.newDoc, }); }, }, }); ``` For runtime behavior, see [API Reference](#api-reference) below. Best Practices [#best-practices] 1. Keep hooks deterministic and table-local. 2. Keep expensive work in `caller.schedule.after()` or `caller.schedule.now`. 3. Index every query path used inside hooks. 4. Prefer `create/update/delete` for specific rules; use `change` for cross-operation logic. 5. If you only need scheduling and not a typed caller, `ctx.scheduler` also works directly. API Reference [#api-reference] Hook Signatures [#hook-signatures] * `create.before(data, ctx)` * `create.after(doc, ctx)` * `update.before(data, ctx)` * `update.after(doc, ctx)` * `delete.before(doc, ctx)` * `delete.after(doc, ctx)` * `change(change, ctx)` Context (`ctx`) includes: * `ctx.db`: wrapped writer (triggers enabled) * `ctx.innerDb`: raw writer (bypasses trigger recursion) * `ctx.orm`: typed ORM writer * your custom context fields (`scheduler`, request ids, etc.) Before Return Behavior [#before-return-behavior] `before` hooks support exactly: * `void`: continue unchanged * `{ data }`: shallow-merge into the write payload * `false`: cancel write and throw `TriggerCancelledError` `after` hooks always return `void`. withoutTriggers [#withouttriggers] Bypass all trigger hooks for a block of operations. Useful for bulk resets, migrations, and data seeding where triggers would interfere or cause infinite recursion. ```ts showLineNumbers await ctx.orm.withoutTriggers(async (orm) => { // These writes skip all before/after/change hooks await orm.delete(todosTable).allowFullScan(); await orm.insert(todosTable).values({ title: "Seed", completed: false, userId }); }); ``` The callback receives a trigger-free ORM instance scoped to the same transaction. Reads and mutations work identically — only hook dispatch is disabled. Runtime Guarantees [#runtime-guarantees] 1. Hooks run only for wrapped mutation contexts (`ctx.orm` / generated server context). 2. Trigger errors fail the mutation. 3. Recursive writes are queued deterministically. 4. `ctx.innerDb` bypasses recursive dispatch (low-level; prefer `withoutTriggers` for most cases). Next Steps [#next-steps]