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. It throws ConvexError('Unauthenticated') if there is no authenticated user, and otherwise returns the user (never null), so no if (!user) guard is needed after it. When unauthenticated should be a valid, non-throwing case, use authComponent.safeGetAuthUser(ctx), which returns undefined instead of throwing.
- 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-guidelines-23description: 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. It throws `ConvexError('Unauthenticated')` if there is no authenticated user, and otherwise returns the user (never `null`), so no `if (!user)` guard is needed after it. When unauthenticated should be a valid, non-throwing case, use `authComponent.safeGetAuthUser(ctx)`, which returns `undefined` instead of throwing.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.