name: convex-guidelines description: 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.
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.tsand require anhttpActiondecorator:
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
pathfield. - For prefix matching use
pathPrefixinstead ofpath: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, andv.object({ ... })for objects. - Discriminated unions: use
v.literal("kind")insidev.union(v.object({ kind: v.literal("a"), ... }), ...). - Common validators:
v.id(tableName),v.string(),v.number(),v.boolean(),v.int64()(notv.bigint()),v.record(keys, values)(notv.map/v.set). - There is NO
v.tuple()validator. Usev.array(v.union(...))for mixed-type arrays. - JavaScript's
undefinedis not a valid Convex value. Functions that returnundefinedor do not return will returnnullwhen called from a client. Usenullinstead. v.record(keys, values): keys must be ASCII characters, nonempty, and not start with$or_.
Function Registration
- Use
internalQuery,internalMutation,internalActionfor private functions (from./_generated/server). Usequery,mutation,actionfor public API. - Do NOT register functions through the
apiorinternalobjects. - ALWAYS include
argsvalidators for every function. - ALWAYS include
returnsvalidators for every function. If a function returns nothing, usereturns: v.null(). - Scheduled retry functions MUST have a max retry count. Add a
retryCountfield to the relevant table and stop retrying after N attempts (typically 5). Log the final failure for observability.
Function Calling
- Use
ctx.runQueryto call a query from a query, mutation, or action. - Use
ctx.runMutationto call a mutation from a mutation or action. - Use
ctx.runActionto 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
apiobject fromconvex/_generated/api.tsto reference public functions (query,mutation,action). - Use the
internalobject fromconvex/_generated/api.tsto reference private functions (internalQuery,internalMutation,internalAction). - Public function
finconvex/example.ts→api.example.f. - Private function
ginconvex/example.ts→internal.example.g. - Nested directories:
convex/messages/access.ts→api.messages.access.h.
Pagination
- Import
paginationOptsValidatorfromconvex/serverand useargs: { paginationOpts: paginationOptsValidator, ... }. - Paginated return object has
page,isDone, andcontinueCursor(NOTresults). - 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.tsand import schema definition functions fromconvex/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"]→ nameby_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()andcreateAuthOptions(). - Use
authComponent.getAuthUser(ctx)to get the current authenticated user in any query, mutation, or action. It throwsConvexError('Unauthenticated')if there is no authenticated user, and otherwise returns the user (nevernull), so noif (!user)guard is needed after it. When unauthenticated should be a valid, non-throwing case, useauthComponent.safeGetAuthUser(ctx), which returnsundefinedinstead of throwing. - NEVER accept a
userIdor any user identifier as a function argument for authorization. Always derive identity server-side viaauthComponent.getAuthUser(ctx). - HTTP auth routes are registered via
authComponent.registerRoutes(http, createAuth)inconvex/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/**requiresrole === 'admin'. - Sign-in/sign-up:
authClient.signIn.email(),authClient.signUp.email(),authClient.signIn.social(),authClient.signIn.passkey().
TypeScript Guidelines
- Use
Id<"tableName">from./_generated/dataModelfor document IDs. Be strict — preferId<"users">overstring. - Use
Doc<"tableName">from./_generated/dataModelfor full document types. - Use
QueryCtx,MutationCtx,ActionCtxfrom./_generated/serverfor typing function contexts. NEVER useanyfor ctx parameters. - Match
Recordkey/value types to the validator:v.record(v.id('users'), v.string())→Record<Id<'users'>, string>.
Query Guidelines
- Do NOT use
filterin queries. Define an index in the schema and usewithIndexinstead. - Convex queries do NOT support
.delete(). Instead,.collect()the results, iterate, and callctx.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(). Usefor await (const row of query)directly.
Ordering
- By default Convex returns documents in ascending
_creationTimeorder. - 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.replaceto fully replace an existing document. Throws if the document does not exist. - Use
ctx.db.patchto 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 usefetch().- Never use
ctx.dbinside of an action. Actions don't have access to the database. Usectx.runQueryorctx.runMutationinstead.
Scheduling Guidelines
Cron Jobs
- Only use
crons.intervalorcrons.cronmethods. Do NOT usecrons.hourly,crons.daily, orcrons.weeklyhelpers. - Both cron methods take a FunctionReference. Do NOT pass the function directly.
- Define crons by declaring the top-level
cronsobject, 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.tsjust like any other file. - If a cron calls an internal function, always import
internalfrom_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. Returnsnullif the file doesn't exist.- Do NOT use the deprecated
ctx.storage.getMetadata. Query the_storagesystem 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
Blobobjects. Convert all items to/from aBlobwhen using storage. - Use
new Blob([data])to store andawait blob.text()to read. Do NOT useTextEncoderorTextDecoderwith Convex storage blobs.