Convex Backend Development
Core Architecture
Convex is a reactive database where queries are TypeScript functions. The sync engine (queries + mutations + database) is the heart of Convex — center your app around it.
Function Types
| Type |
DB Access |
Deterministic |
Cached/Reactive |
Use For |
query |
Read only |
Yes |
Yes |
All reads, subscriptions |
mutation |
Read/Write |
Yes |
No |
All writes (transactions) |
action |
Via ctx.run* |
No |
No |
External APIs, LLMs, email |
httpAction |
Via ctx.run* |
No |
No |
Webhooks, custom HTTP |
Key rule: Queries and mutations cannot make network requests. Actions cannot directly access the database.
Project Structure (Best Practice)
convex/
├── _generated/ # Auto-generated types (commit this)
├── schema.ts # Database schema
├── model/ # Helper functions (most logic lives here)
│ ├── users.ts
│ └── messages.ts
├── users.ts # Thin wrappers exposing public API
├── messages.ts
├── crons.ts # Cron job definitions
└── http.ts # HTTP action routes
Essential Patterns
1. Function Structure
// convex/messages.ts
import { query, mutation, internalMutation } from './_generated/server';
import { internal } from './_generated/api';
import { v } from 'convex/values';
// PUBLIC query with validators (always validate public functions)
export const list = query({
args: { channelId: v.id('channels') },
handler: async (ctx, { channelId }) => {
return await ctx.db
.query('messages')
.withIndex('by_channel', (q) => q.eq('channelId', channelId))
.order('desc')
.take(50);
}
});
// PUBLIC mutation with validators and auth check
export const send = mutation({
args: { channelId: v.id('channels'), body: v.string() },
handler: async (ctx, { channelId, body }) => {
const user = await ctx.auth.getUserIdentity();
if (!user) throw new Error('Unauthorized');
await ctx.db.insert('messages', {
channelId,
body,
authorId: user.subject
});
}
});
// INTERNAL mutation (for scheduling, crons, actions)
export const deleteOld = internalMutation({
args: { before: v.number() },
handler: async (ctx, { before }) => {
const old = await ctx.db
.query('messages')
.withIndex('by_createdAt', (q) => q.lt('_creationTime', before))
.take(100);
for (const msg of old) {
await ctx.db.delete(msg._id);
}
}
});
2. Helper Functions Pattern
Most logic should live in helper functions, NOT in query/mutation handlers:
// convex/model/users.ts
import { QueryCtx, MutationCtx } from '../_generated/server';
import { Doc } from '../_generated/dataModel';
export async function getCurrentUser(
ctx: QueryCtx
): Promise<Doc<'users'> | null> {
const identity = await ctx.auth.getUserIdentity();
if (!identity) return null;
return await ctx.db
.query('users')
.withIndex('by_tokenIdentifier', (q) =>
q.eq('tokenIdentifier', identity.tokenIdentifier)
)
.unique();
}
export async function requireUser(ctx: QueryCtx): Promise<Doc<'users'>> {
const user = await getCurrentUser(ctx);
if (!user) throw new Error('Unauthorized');
return user;
}
3. Actions with Scheduling
// convex/ai.ts
import { action, internalMutation } from './_generated/server';
import { internal } from './_generated/api';
import { v } from 'convex/values';
export const summarize = action({
args: { documentId: v.id('documents') },
handler: async (ctx, { documentId }) => {
// Read data via internal query
const doc = await ctx.runQuery(internal.documents.get, { documentId });
// Call external API
const response = await fetch('https://api.openai.com/v1/...', {...});
const summary = await response.json();
// Write result via internal mutation
await ctx.runMutation(internal.documents.setSummary, {
documentId,
summary: summary.text
});
}
});
// Trigger action from mutation (not directly from client)
export const requestSummary = mutation({
args: { documentId: v.id('documents') },
handler: async (ctx, { documentId }) => {
const user = await ctx.auth.getUserIdentity();
if (!user) throw new Error('Unauthorized');
await ctx.db.patch(documentId, { status: 'processing' });
// Schedule action (runs after mutation commits)
await ctx.scheduler.runAfter(0, internal.ai.summarizeInternal, {
documentId
});
}
});
4. Application Errors
import { ConvexError } from 'convex/values';
export const assignRole = mutation({
args: { roleId: v.id('roles'), userId: v.id('users') },
handler: async (ctx, { roleId, userId }) => {
const existing = await ctx.db
.query('assignments')
.withIndex('by_role', (q) => q.eq('roleId', roleId))
.first();
if (existing) {
throw new ConvexError({
code: 'ROLE_TAKEN',
message: 'Role is already assigned'
});
}
await ctx.db.insert('assignments', { roleId, userId });
}
});
Critical Rules
DO ✅
- Use
internal. functions for all ctx.run*, ctx.scheduler, and crons
- Always validate args for public functions with
v.* validators
- Always check auth in public functions:
ctx.auth.getUserIdentity()
- Use indexes with
.withIndex() instead of .filter()
- Await all promises (enable
no-floating-promises ESLint rule)
- Keep actions small — put logic in queries/mutations
- Batch database operations in single mutations
- Use
ConvexError for user-facing errors
DON'T ❌
- Don't use
api. functions for scheduling (use internal.)
- Don't use
.filter() on queries — use indexes or TypeScript filter
- Don't use
.collect() on unbounded queries (use .take() or pagination)
- Don't use
Date.now() in queries (breaks caching)
- Don't call actions directly from client (trigger via mutation + scheduler)
- Don't make sequential
ctx.runQuery/runMutation calls in actions (batch them)
- Don't use
ctx.runAction unless switching runtimes (use helper functions)
Reference Guides
For detailed patterns, see:
- FUNCTIONS.md — Queries, mutations, actions, internal functions
- VALIDATION.md — Argument validation, extended validators
- ERROR_HANDLING.md — ConvexError, application errors
- HTTP_ACTIONS.md — HTTP actions, CORS, webhooks
- RUNTIMES.md — Default vs Node.js runtime, bundling, debugging
- DATABASE.md — Schema, indexes, reading/writing data
- SEARCH.md — Full-text search, vector search, RAG patterns
- ADVANCED.md — System tables, schema philosophy, OCC
- AUTH.md — Row-level security, Convex Auth (first-party)
- SCHEDULING.md — Crons, scheduled functions, workflows
- FILE_STORAGE.md — Upload, store, serve, delete files
- NEXTJS.md — Next.js App Router, SSR, Server Actions
Auth Provider Skills (add to project as needed):
convex-auth — Universal auth patterns, storing users, debugging
convex-clerk — Clerk setup, webhooks, JWT configuration
convex-workos — WorkOS AuthKit setup, auto-provisioning
1---2name: convex-23description: Expert guidance for Convex backend development including queries, mutations, actions, schemas, authentication, scheduling, file storage, search, and Next.js integration. Use when working with Convex functions, database operations, convex/ directory code, or Next.js App Router with Convex. Triggers: convex functions, ctx.db, useQuery, useMutation, usePreloadedQuery, preloadQuery, fetchQuery, convex schema, convex auth, convex cron, convex actions, convex scheduling, ctx.storage, generateUploadUrl, file upload, storage.store, storage.getUrl, Id<"_storage">, ConvexProvider, ConvexClientProvider, db.system, _scheduled_functions, _storage system table, OCC, optimistic concurrency, transaction atomicity, schema evolution, searchIndex, vectorIndex, withSearchIndex, ctx.vectorSearch, full-text search, vector search, embeddings, RAG, semantic search, typeahead search, ConvexError, httpAction, httpRouter, CORS, webhook.4---5
6# Convex Backend Development
7
8## Core Architecture
9
10Convex is a reactive database where queries are TypeScript functions. The sync engine (queries + mutations + database) is the heart of Convex — center your app around it.
11
12### Function Types
13
14| Type | DB Access | Deterministic | Cached/Reactive | Use For |
15| ------------ | ------------- | ------------- | --------------- | -------------------------- |
16| `query` | Read only | Yes | Yes | All reads, subscriptions |
17| `mutation` | Read/Write | Yes | No | All writes (transactions) |
18| `action` | Via ctx.run\* | No | No | External APIs, LLMs, email |
19| `httpAction` | Via ctx.run\* | No | No | Webhooks, custom HTTP |
20
21**Key rule**: Queries and mutations cannot make network requests. Actions cannot directly access the database.
22
23## Project Structure (Best Practice)
24
25```
26convex/
27├── _generated/ # Auto-generated types (commit this)
28├── schema.ts # Database schema
29├── model/ # Helper functions (most logic lives here)
30│ ├── users.ts
31│ └── messages.ts
32├── users.ts # Thin wrappers exposing public API
33├── messages.ts
34├── crons.ts # Cron job definitions
35└── http.ts # HTTP action routes
36```
37
38## Essential Patterns
39
40### 1. Function Structure
41
42```typescript
43// convex/messages.ts
44import { query, mutation, internalMutation } from './_generated/server';
45import { internal } from './_generated/api';
46import { v } from 'convex/values';
47
48// PUBLIC query with validators (always validate public functions)
49export const list = query({
50 args: { channelId: v.id('channels') },
51 handler: async (ctx, { channelId }) => {
52 return await ctx.db
53 .query('messages')
54 .withIndex('by_channel', (q) => q.eq('channelId', channelId))
55 .order('desc')
56 .take(50);
57 }
58});
59
60// PUBLIC mutation with validators and auth check
61export const send = mutation({
62 args: { channelId: v.id('channels'), body: v.string() },
63 handler: async (ctx, { channelId, body }) => {
64 const user = await ctx.auth.getUserIdentity();
65 if (!user) throw new Error('Unauthorized');
66
67 await ctx.db.insert('messages', {
68 channelId,
69 body,
70 authorId: user.subject
71 });
72 }
73});
74
75// INTERNAL mutation (for scheduling, crons, actions)
76export const deleteOld = internalMutation({
77 args: { before: v.number() },
78 handler: async (ctx, { before }) => {
79 const old = await ctx.db
80 .query('messages')
81 .withIndex('by_createdAt', (q) => q.lt('_creationTime', before))
82 .take(100);
83 for (const msg of old) {
84 await ctx.db.delete(msg._id);
85 }
86 }
87});
88```
89
90### 2. Helper Functions Pattern
91
92Most logic should live in helper functions, NOT in query/mutation handlers:
93
94```typescript
95// convex/model/users.ts
96import { QueryCtx, MutationCtx } from '../_generated/server';
97import { Doc } from '../_generated/dataModel';
98
99export async function getCurrentUser(
100 ctx: QueryCtx
101): Promise<Doc<'users'> | null> {
102 const identity = await ctx.auth.getUserIdentity();
103 if (!identity) return null;
104
105 return await ctx.db
106 .query('users')
107 .withIndex('by_tokenIdentifier', (q) =>
108 q.eq('tokenIdentifier', identity.tokenIdentifier)
109 )
110 .unique();
111}
112
113export async function requireUser(ctx: QueryCtx): Promise<Doc<'users'>> {
114 const user = await getCurrentUser(ctx);
115 if (!user) throw new Error('Unauthorized');
116 return user;
117}
118```
119
120### 3. Actions with Scheduling
121
122```typescript
123// convex/ai.ts
124import { action, internalMutation } from './_generated/server';
125import { internal } from './_generated/api';
126import { v } from 'convex/values';
127
128export const summarize = action({
129 args: { documentId: v.id('documents') },
130 handler: async (ctx, { documentId }) => {
131 // Read data via internal query
132 const doc = await ctx.runQuery(internal.documents.get, { documentId });
133
134 // Call external API
135 const response = await fetch('https://api.openai.com/v1/...', {...});
136 const summary = await response.json();
137
138 // Write result via internal mutation
139 await ctx.runMutation(internal.documents.setSummary, {
140 documentId,
141 summary: summary.text
142 });
143 }
144});
145
146// Trigger action from mutation (not directly from client)
147export const requestSummary = mutation({
148 args: { documentId: v.id('documents') },
149 handler: async (ctx, { documentId }) => {
150 const user = await ctx.auth.getUserIdentity();
151 if (!user) throw new Error('Unauthorized');
152
153 await ctx.db.patch(documentId, { status: 'processing' });
154
155 // Schedule action (runs after mutation commits)
156 await ctx.scheduler.runAfter(0, internal.ai.summarizeInternal, {
157 documentId
158 });
159 }
160});
161```
162
163### 4. Application Errors
164
165```typescript
166import { ConvexError } from 'convex/values';
167
168export const assignRole = mutation({
169 args: { roleId: v.id('roles'), userId: v.id('users') },
170 handler: async (ctx, { roleId, userId }) => {
171 const existing = await ctx.db
172 .query('assignments')
173 .withIndex('by_role', (q) => q.eq('roleId', roleId))
174 .first();
175
176 if (existing) {
177 throw new ConvexError({
178 code: 'ROLE_TAKEN',
179 message: 'Role is already assigned'
180 });
181 }
182
183 await ctx.db.insert('assignments', { roleId, userId });
184 }
185});
186```
187
188## Critical Rules
189
190### DO ✅
191
192- Use `internal.` functions for all `ctx.run*`, `ctx.scheduler`, and crons
193- Always validate args for public functions with `v.*` validators
194- Always check auth in public functions: `ctx.auth.getUserIdentity()`
195- Use indexes with `.withIndex()` instead of `.filter()`
196- Await all promises (enable `no-floating-promises` ESLint rule)
197- Keep actions small — put logic in queries/mutations
198- Batch database operations in single mutations
199- Use `ConvexError` for user-facing errors
200
201### DON'T ❌
202
203- Don't use `api.` functions for scheduling (use `internal.`)
204- Don't use `.filter()` on queries — use indexes or TypeScript filter
205- Don't use `.collect()` on unbounded queries (use `.take()` or pagination)
206- Don't use `Date.now()` in queries (breaks caching)
207- Don't call actions directly from client (trigger via mutation + scheduler)
208- Don't make sequential `ctx.runQuery/runMutation` calls in actions (batch them)
209- Don't use `ctx.runAction` unless switching runtimes (use helper functions)
210
211## Reference Guides
212
213For detailed patterns, see:
214
215- [FUNCTIONS.md](references/FUNCTIONS.md) — Queries, mutations, actions, internal functions
216- [VALIDATION.md](references/VALIDATION.md) — Argument validation, extended validators
217- [ERROR_HANDLING.md](references/ERROR_HANDLING.md) — ConvexError, application errors
218- [HTTP_ACTIONS.md](references/HTTP_ACTIONS.md) — HTTP actions, CORS, webhooks
219- [RUNTIMES.md](references/RUNTIMES.md) — Default vs Node.js runtime, bundling, debugging
220- [DATABASE.md](references/DATABASE.md) — Schema, indexes, reading/writing data
221- [SEARCH.md](references/SEARCH.md) — Full-text search, vector search, RAG patterns
222- [ADVANCED.md](references/ADVANCED.md) — System tables, schema philosophy, OCC
223- [AUTH.md](references/AUTH.md) — Row-level security, Convex Auth (first-party)
224- [SCHEDULING.md](references/SCHEDULING.md) — Crons, scheduled functions, workflows
225- [FILE_STORAGE.md](references/FILE_STORAGE.md) — Upload, store, serve, delete files
226- [NEXTJS.md](references/NEXTJS.md) — Next.js App Router, SSR, Server Actions
227
228**Auth Provider Skills** (add to project as needed):
229
230- `convex-auth` — Universal auth patterns, storing users, debugging
231- `convex-clerk` — Clerk setup, webhooks, JWT configuration
232- `convex-workos` — WorkOS AuthKit setup, auto-provisioning