Convex Coding Guidelines
These guidelines must be followed when writing, reviewing, or modifying any Convex backend code.
Function Guidelines
HTTP Endpoint Syntax
- HTTP endpoints are defined in
convex/http.ts and require an httpAction decorator:
import { httpRouter } from 'convex/server';
import { httpAction } from './_generated/server';
const http = httpRouter();
http.route({
path: '/echo',
method: 'POST',
handler: httpAction(async (ctx, req) => {
const body = await req.bytes();
return new Response(body, { status: 200 });
})
});
- HTTP endpoints are registered at the exact path you specify in the
path field.
- For prefix matching use
pathPrefix instead of path: http.route({ pathPrefix: "/api/", method: "GET", handler: ... }). Do NOT use glob patterns like /api/*.
Validators
- Use
v.array(validator) for arrays, v.union(...) for unions, and v.object({ ... }) for objects.
- Discriminated unions: use
v.literal("kind") inside v.union(v.object({ kind: v.literal("a"), ... }), ...).
- Common validators:
v.id(tableName), v.string(), v.number(), v.boolean(), v.int64() (not v.bigint()), v.record(keys, values) (not v.map/v.set).
- There is NO
v.tuple() validator. Use v.array(v.union(...)) for mixed-type arrays.
- JavaScript's
undefined is not a valid Convex value. Functions that return undefined or do not return will return null when called from a client. Use null instead.
v.record(keys, values): keys must be ASCII characters, nonempty, and not start with $ or _.
Function Registration
- Use
internalQuery, internalMutation, internalAction for private functions (from ./_generated/server). Use query, mutation, action for public API.
- Do NOT register functions through the
api or internal objects.
- ALWAYS include
args validators for every function.
- ALWAYS include
returns validators for every function. If a function returns nothing, use returns: v.null().
- Scheduled retry functions MUST have a max retry count. Add a
retryCount field to the relevant table and stop retrying after N attempts (typically 5). Log the final failure for observability.
Function Calling
- Use
ctx.runQuery to call a query from a query, mutation, or action.
- Use
ctx.runMutation to call a mutation from a mutation or action.
- Use
ctx.runAction to call an action from an action.
- Only call an action from another action when crossing runtimes (e.g. V8 to Node). Otherwise extract shared logic into a helper async function.
- Minimize action-to-query/mutation calls; each call is a separate transaction and can introduce race conditions.
- All calls take a FunctionReference (e.g.
api.module.f). Do NOT pass the function directly.
- For same-file calls, add a type annotation on the return value to avoid TypeScript circularity:
export const f = query({
args: { name: v.string() },
returns: v.string(),
handler: async (ctx, args) => {
return 'Hello ' + args.name;
}
});
export const g = query({
args: {},
returns: v.null(),
handler: async (ctx, args) => {
const result: string = await ctx.runQuery(api.example.f, { name: 'Bob' });
return null;
}
});
Function References (File-Based Routing)
- Use the
api object from convex/_generated/api.ts to reference public functions (query, mutation, action).
- Use the
internal object from convex/_generated/api.ts to reference private functions (internalQuery, internalMutation, internalAction).
- Public function
f in convex/example.ts → api.example.f.
- Private function
g in convex/example.ts → internal.example.g.
- Nested directories:
convex/messages/access.ts → api.messages.access.h.
Pagination
- Import
paginationOptsValidator from convex/server and use args: { paginationOpts: paginationOptsValidator, ... }.
- Paginated return object has
page, isDone, and continueCursor (NOT results).
- Example:
import { query } from './_generated/server';
import { v } from 'convex/values';
import { paginationOptsValidator } from 'convex/server';
export const list = query({
args: { paginationOpts: paginationOptsValidator, author: v.string() },
handler: async (ctx, args) => {
return await ctx.db
.query('messages')
.withIndex('by_author', (q) => q.eq('author', args.author))
.order('desc')
.paginate(args.paginationOpts);
}
});
Schema Guidelines
- Always define your schema in
convex/schema.ts and import schema definition functions from convex/server.
- System fields
_creationTime (v.number()) and _id (v.id(tableName)) are automatic — never define them manually.
- Include all index fields in the index name: index on
["field1", "field2"] → name by_field1_and_field2.
- Index fields must be queried in the order they are defined. To query in a different order, create a separate index.
Authentication Guidelines (Better Auth + Convex)
This section applies to projects using @convex-dev/better-auth with a local install — NOT vanilla Convex JWT auth (auth.config.ts + ctx.auth.getUserIdentity()).
Server-side (Convex Backend)
- Auth is configured via
createAuth() and createAuthOptions().
- Use
authComponent.getAuthUser(ctx) to get the current authenticated user in any query, mutation, or action. Returns null if unauthenticated.
- NEVER accept a
userId or any user identifier as a function argument for authorization. Always derive identity server-side via authComponent.getAuthUser(ctx).
- HTTP auth routes are registered via
authComponent.registerRoutes(http, createAuth) in convex/http.ts.
- Auth tables (
user, session, account, verification, jwks, passkey) are managed by the Better Auth component.
- Supported auth methods: email/password, OAuth (Google, GitHub), passkeys.
Client-side (SvelteKit)
- Auth client is created via
createAuthClient() with plugins: convexClient(), passkeyClient(), adminClient().
- Use
useAuth() for reactive auth state (isAuthenticated, session, user).
- Route protection is handled in
hooks.server.ts: JWT extracted from cookies, /app/** requires auth, /admin/** requires role === 'admin'.
- Sign-in/sign-up:
authClient.signIn.email(), authClient.signUp.email(), authClient.signIn.social(), authClient.signIn.passkey().
TypeScript Guidelines
- Use
Id<"tableName"> from ./_generated/dataModel for document IDs. Be strict — prefer Id<"users"> over string.
- Use
Doc<"tableName"> from ./_generated/dataModel for full document types.
- Use
QueryCtx, MutationCtx, ActionCtx from ./_generated/server for typing function contexts. NEVER use any for ctx parameters.
- Match
Record key/value types to the validator: v.record(v.id('users'), v.string()) → Record<Id<'users'>, string>.
Query Guidelines
- Do NOT use
filter in queries. Define an index in the schema and use withIndex instead.
- Convex queries do NOT support
.delete(). Instead, .collect() the results, iterate, and call ctx.db.delete(row._id) on each.
- Use
.unique() to get a single document. Throws if multiple documents match.
- When using async iteration, do NOT use
.collect(), .take(n), or .iter(). Use for await (const row of query) directly.
Ordering
- By default Convex returns documents in ascending
_creationTime order.
- Use
.order('asc') or .order('desc') to set order. Defaults to ascending.
- Queries using indexes are ordered based on the index columns and avoid slow table scans.
Full-Text Search
Use .withSearchIndex() for text search queries:
const messages = await ctx.db
.query('messages')
.withSearchIndex('search_body', (q) => q.search('body', 'hello hi').eq('channel', '#general'))
.take(10);
Mutation Guidelines
- Use
ctx.db.replace to fully replace an existing document. Throws if the document does not exist.
- Use
ctx.db.patch to shallow merge updates into an existing document. Throws if the document does not exist.
Action Guidelines
- Always add
"use node"; to the top of files containing actions that use Node.js built-in modules.
- NEVER add
"use node"; to a file that also exports queries or mutations. Only actions can run in the Node.js runtime; queries and mutations must stay in the default Convex runtime. If you need Node.js built-ins alongside queries or mutations, put the action in a separate file.
fetch() is available in the default Convex runtime. You do NOT need "use node"; just to use fetch().
- Never use
ctx.db inside of an action. Actions don't have access to the database. Use ctx.runQuery or ctx.runMutation instead.
Scheduling Guidelines
Cron Jobs
- Only use
crons.interval or crons.cron methods. Do NOT use crons.hourly, crons.daily, or crons.weekly helpers.
- Both cron methods take a FunctionReference. Do NOT pass the function directly.
- Define crons by declaring the top-level
crons object, calling methods on it, and exporting it as default:
import { cronJobs } from 'convex/server';
import { internal } from './_generated/api';
import { internalAction } from './_generated/server';
const empty = internalAction({
args: {},
handler: async (ctx, args) => {
console.log('empty');
}
});
const crons = cronJobs();
crons.interval('delete inactive users', { hours: 2 }, internal.crons.empty, {});
export default crons;
- You can register Convex functions within
crons.ts just like any other file.
- If a cron calls an internal function, always import
internal from _generated/api, even if the function is registered in the same file.
File Storage Guidelines
ctx.storage.getUrl() returns a signed URL for a given file. Returns null if the file doesn't exist.
- Do NOT use the deprecated
ctx.storage.getMetadata. Query the _storage system table instead:
import { query } from './_generated/server';
import { v } from 'convex/values';
export const getFileMetadata = query({
args: { fileId: v.id('_storage') },
returns: v.any(),
handler: async (ctx, args) => {
return await ctx.db.system.get(args.fileId);
// Returns: { _id, _creationTime, contentType?, sha256, size }
}
});
- Convex storage stores items as
Blob objects. Convert all items to/from a Blob when using storage.
- Use
new Blob([data]) to store and await blob.text() to read. Do NOT use TextEncoder or TextDecoder with Convex storage blobs.
1---2name: convex-guidelines3description: Canonical Convex backend coding patterns — validators, function registration, queries, mutations, actions, schemas, pagination, cron jobs, file storage, and Better Auth integration. Use when writing or reviewing any Convex backend code.4---56# Convex Coding Guidelines78These guidelines must be followed when writing, reviewing, or modifying any Convex backend code.910## Function Guidelines1112### HTTP Endpoint Syntax1314- HTTP endpoints are defined in `convex/http.ts` and require an `httpAction` decorator:1516```typescript17import { httpRouter } from 'convex/server';18import { httpAction } from './_generated/server';19const http = httpRouter();20http.route({21 path: '/echo',22 method: 'POST',23 handler: httpAction(async (ctx, req) => {24 const body = await req.bytes();25 return new Response(body, { status: 200 });26 })27});28```2930- HTTP endpoints are registered at the exact path you specify in the `path` field.31- For prefix matching use `pathPrefix` instead of `path`: `http.route({ pathPrefix: "/api/", method: "GET", handler: ... })`. Do NOT use glob patterns like `/api/*`.3233### Validators3435- Use `v.array(validator)` for arrays, `v.union(...)` for unions, and `v.object({ ... })` for objects.36- Discriminated unions: use `v.literal("kind")` inside `v.union(v.object({ kind: v.literal("a"), ... }), ...)`.37- Common validators: `v.id(tableName)`, `v.string()`, `v.number()`, `v.boolean()`, `v.int64()` (not `v.bigint()`), `v.record(keys, values)` (not `v.map`/`v.set`).38- There is NO `v.tuple()` validator. Use `v.array(v.union(...))` for mixed-type arrays.39- JavaScript's `undefined` is not a valid Convex value. Functions that return `undefined` or do not return will return `null` when called from a client. Use `null` instead.40- `v.record(keys, values)`: keys must be ASCII characters, nonempty, and not start with `$` or `_`.4142### Function Registration4344- Use `internalQuery`, `internalMutation`, `internalAction` for private functions (from `./_generated/server`). Use `query`, `mutation`, `action` for public API.45- Do NOT register functions through the `api` or `internal` objects.46- ALWAYS include `args` validators for every function.47- ALWAYS include `returns` validators for every function. If a function returns nothing, use `returns: v.null()`.48- **Scheduled retry functions MUST have a max retry count.** Add a `retryCount` field to the relevant table and stop retrying after N attempts (typically 5). Log the final failure for observability.4950### Function Calling5152- Use `ctx.runQuery` to call a query from a query, mutation, or action.53- Use `ctx.runMutation` to call a mutation from a mutation or action.54- Use `ctx.runAction` to call an action from an action.55- Only call an action from another action when crossing runtimes (e.g. V8 to Node). Otherwise extract shared logic into a helper async function.56- Minimize action-to-query/mutation calls; each call is a separate transaction and can introduce race conditions.57- All calls take a FunctionReference (e.g. `api.module.f`). Do NOT pass the function directly.58- For same-file calls, add a type annotation on the return value to avoid TypeScript circularity:5960```typescript61export const f = query({62 args: { name: v.string() },63 returns: v.string(),64 handler: async (ctx, args) => {65 return 'Hello ' + args.name;66 }67});6869export const g = query({70 args: {},71 returns: v.null(),72 handler: async (ctx, args) => {73 const result: string = await ctx.runQuery(api.example.f, { name: 'Bob' });74 return null;75 }76});77```7879### Function References (File-Based Routing)8081- Use the `api` object from `convex/_generated/api.ts` to reference public functions (`query`, `mutation`, `action`).82- Use the `internal` object from `convex/_generated/api.ts` to reference private functions (`internalQuery`, `internalMutation`, `internalAction`).83- Public function `f` in `convex/example.ts` → `api.example.f`.84- Private function `g` in `convex/example.ts` → `internal.example.g`.85- Nested directories: `convex/messages/access.ts` → `api.messages.access.h`.8687### Pagination8889- Import `paginationOptsValidator` from `convex/server` and use `args: { paginationOpts: paginationOptsValidator, ... }`.90- Paginated return object has `page`, `isDone`, and `continueCursor` (NOT `results`).91- Example:9293```typescript94import { query } from './_generated/server';95import { v } from 'convex/values';96import { paginationOptsValidator } from 'convex/server';9798export const list = query({99 args: { paginationOpts: paginationOptsValidator, author: v.string() },100 handler: async (ctx, args) => {101 return await ctx.db102 .query('messages')103 .withIndex('by_author', (q) => q.eq('author', args.author))104 .order('desc')105 .paginate(args.paginationOpts);106 }107});108```109110## Schema Guidelines111112- Always define your schema in `convex/schema.ts` and import schema definition functions from `convex/server`.113- System fields `_creationTime` (`v.number()`) and `_id` (`v.id(tableName)`) are automatic — never define them manually.114- Include all index fields in the index name: index on `["field1", "field2"]` → name `by_field1_and_field2`.115- Index fields must be queried in the order they are defined. To query in a different order, create a separate index.116117## Authentication Guidelines (Better Auth + Convex)118119This section applies to projects using `@convex-dev/better-auth` with a local install — NOT vanilla Convex JWT auth (`auth.config.ts` + `ctx.auth.getUserIdentity()`).120121### Server-side (Convex Backend)122123- Auth is configured via `createAuth()` and `createAuthOptions()`.124- Use `authComponent.getAuthUser(ctx)` to get the current authenticated user in any query, mutation, or action. Returns `null` if unauthenticated.125- NEVER accept a `userId` or any user identifier as a function argument for authorization. Always derive identity server-side via `authComponent.getAuthUser(ctx)`.126- HTTP auth routes are registered via `authComponent.registerRoutes(http, createAuth)` in `convex/http.ts`.127- Auth tables (`user`, `session`, `account`, `verification`, `jwks`, `passkey`) are managed by the Better Auth component.128- Supported auth methods: email/password, OAuth (Google, GitHub), passkeys.129130### Client-side (SvelteKit)131132- Auth client is created via `createAuthClient()` with plugins: `convexClient()`, `passkeyClient()`, `adminClient()`.133- Use `useAuth()` for reactive auth state (`isAuthenticated`, `session`, `user`).134- Route protection is handled in `hooks.server.ts`: JWT extracted from cookies, `/app/**` requires auth, `/admin/**` requires `role === 'admin'`.135- Sign-in/sign-up: `authClient.signIn.email()`, `authClient.signUp.email()`, `authClient.signIn.social()`, `authClient.signIn.passkey()`.136137## TypeScript Guidelines138139- Use `Id<"tableName">` from `./_generated/dataModel` for document IDs. Be strict — prefer `Id<"users">` over `string`.140- Use `Doc<"tableName">` from `./_generated/dataModel` for full document types.141- Use `QueryCtx`, `MutationCtx`, `ActionCtx` from `./_generated/server` for typing function contexts. NEVER use `any` for ctx parameters.142- Match `Record` key/value types to the validator: `v.record(v.id('users'), v.string())` → `Record<Id<'users'>, string>`.143144## Query Guidelines145146- Do NOT use `filter` in queries. Define an index in the schema and use `withIndex` instead.147- Convex queries do NOT support `.delete()`. Instead, `.collect()` the results, iterate, and call `ctx.db.delete(row._id)` on each.148- Use `.unique()` to get a single document. Throws if multiple documents match.149- When using async iteration, do NOT use `.collect()`, `.take(n)`, or `.iter()`. Use `for await (const row of query)` directly.150151### Ordering152153- By default Convex returns documents in ascending `_creationTime` order.154- Use `.order('asc')` or `.order('desc')` to set order. Defaults to ascending.155- Queries using indexes are ordered based on the index columns and avoid slow table scans.156157## Full-Text Search158159Use `.withSearchIndex()` for text search queries:160161```typescript162const messages = await ctx.db163 .query('messages')164 .withSearchIndex('search_body', (q) => q.search('body', 'hello hi').eq('channel', '#general'))165 .take(10);166```167168## Mutation Guidelines169170- Use `ctx.db.replace` to fully replace an existing document. Throws if the document does not exist.171- Use `ctx.db.patch` to shallow merge updates into an existing document. Throws if the document does not exist.172173## Action Guidelines174175- Always add `"use node";` to the top of files containing actions that use Node.js built-in modules.176- NEVER add `"use node";` to a file that also exports queries or mutations. Only actions can run in the Node.js runtime; queries and mutations must stay in the default Convex runtime. If you need Node.js built-ins alongside queries or mutations, put the action in a separate file.177- `fetch()` is available in the default Convex runtime. You do NOT need `"use node";` just to use `fetch()`.178- Never use `ctx.db` inside of an action. Actions don't have access to the database. Use `ctx.runQuery` or `ctx.runMutation` instead.179180## Scheduling Guidelines181182### Cron Jobs183184- Only use `crons.interval` or `crons.cron` methods. Do NOT use `crons.hourly`, `crons.daily`, or `crons.weekly` helpers.185- Both cron methods take a FunctionReference. Do NOT pass the function directly.186- Define crons by declaring the top-level `crons` object, calling methods on it, and exporting it as default:187188```typescript189import { cronJobs } from 'convex/server';190import { internal } from './_generated/api';191import { internalAction } from './_generated/server';192193const empty = internalAction({194 args: {},195 handler: async (ctx, args) => {196 console.log('empty');197 }198});199200const crons = cronJobs();201crons.interval('delete inactive users', { hours: 2 }, internal.crons.empty, {});202export default crons;203```204205- You can register Convex functions within `crons.ts` just like any other file.206- If a cron calls an internal function, always import `internal` from `_generated/api`, even if the function is registered in the same file.207208## File Storage Guidelines209210- `ctx.storage.getUrl()` returns a signed URL for a given file. Returns `null` if the file doesn't exist.211- Do NOT use the deprecated `ctx.storage.getMetadata`. Query the `_storage` system table instead:212213```typescript214import { query } from './_generated/server';215import { v } from 'convex/values';216217export const getFileMetadata = query({218 args: { fileId: v.id('_storage') },219 returns: v.any(),220 handler: async (ctx, args) => {221 return await ctx.db.system.get(args.fileId);222 // Returns: { _id, _creationTime, contentType?, sha256, size }223 }224});225```226227- Convex storage stores items as `Blob` objects. Convert all items to/from a `Blob` when using storage.228- Use `new Blob([data])` to store and `await blob.text()` to read. Do NOT use `TextEncoder` or `TextDecoder` with Convex storage blobs.