convex-best-practices
waynesutton/convexskills
Guidelines for building production-ready Convex apps with best practices for functions, queries, validation, and error handling.
What is convex-best-practices?
Convex Best Practices provides patterns for organizing Convex functions, optimizing queries with indexes, validating arguments and returns with TypeScript, handling errors with ConvexError, and avoiding write conflicts through idempotent mutations. Use this when building scalable Convex applications that need to follow established conventions for code quality and maintainability.
- Enforce code quality rules via @convex-dev/eslint-plugin for function syntax, validators, and imports
- Organize functions by domain with proper argument and return type validation
- Optimize queries using indexes instead of filters for efficient data retrieval
- Handle errors with ConvexError for user-facing messages and proper error codes
- Minimize write conflicts using idempotent mutations and optimistic concurrency control patterns
- Leverage TypeScript end-to-end with Id and Doc types for type-safe document references
How to install convex-best-practices
npx skills add https://github.com/waynesutton/convexskills --skill convex-best-practices- Existing Convex project (npx create-convex-app)
- Node.js and npm installed
- Familiarity with Convex query and mutation functions
How to use convex-best-practices
- 1.Install @convex-dev/eslint-plugin: npm i @convex-dev/eslint-plugin --save-dev
- 2.Configure eslint.config.js with convexPlugin.configs.recommended
- 3.Organize functions by domain in separate files (e.g., convex/users.ts, convex/tasks.ts)
- 4.Define validators for all function arguments using v.object(), v.string(), etc.
- 5.Add return type validators to all queries and mutations
- 6.Create indexes in schema.ts for frequently queried field combinations
- 7.Use withIndex() in queries to leverage indexes instead of filtering
- 8.Wrap error conditions with ConvexError for user-facing messages
Use cases
- Setting up ESLint rules to enforce Convex function syntax and validator requirements across a team
- Designing efficient query patterns with multi-field indexes for user-scoped data retrieval
- Implementing idempotent mutations to handle concurrent updates without write conflicts
- Creating CRUD operations with proper validation on both arguments and return types
- Organizing large Convex codebases by domain (users.ts, tasks.ts, etc.) with consistent patterns
- Backend developers building Convex applications
- Teams adopting Convex for the first time seeking established patterns
- Developers migrating to production-ready Convex architectures
- TypeScript developers wanting end-to-end type safety in serverless functions
convex-best-practices FAQ
Five core principles: Convex manages hard parts (caching, sync, consistency), functions are your API, schema is truth, use TypeScript everywhere, and think of queries as reactive subscriptions rather than one-off requests.
Always use indexes for queries that filter on specific fields. Define indexes in schema.ts for common query patterns (e.g., by_user, by_user_and_status) and query with withIndex() instead of filter().
Use optimistic concurrency control: make mutations idempotent (early return if already done), patch directly without reading first when possible, and use Promise.all() for parallel independent updates.
ConvexError is for user-facing errors with code and message fields. Throw it in mutations/queries when validation fails or resources don't exist. It's serialized to the client with proper error codes.
Yes. Define returns validators on all queries and mutations using v.object(), v.array(), etc. This ensures type safety end-to-end and documents your API contract.
Full instructions (SKILL.md)
Source of truth, from waynesutton/convexskills.
name: convex-best-practices description: Guidelines for building production-ready Convex apps covering function organization, query patterns, validation, TypeScript usage, error handling, and the Zen of Convex design philosophy
Convex Best Practices
Build production-ready Convex applications by following established patterns for function organization, query optimization, validation, TypeScript usage, and error handling.
Code Quality
All patterns in this skill comply with @convex-dev/eslint-plugin. Install it for build-time validation:
npm i @convex-dev/eslint-plugin --save-dev
// eslint.config.js
import { defineConfig } from "eslint/config";
import convexPlugin from "@convex-dev/eslint-plugin";
export default defineConfig([
...convexPlugin.configs.recommended,
]);
The plugin enforces four rules:
| Rule | What it enforces |
|---|---|
no-old-registered-function-syntax | Object syntax with handler |
require-argument-validators | args: {} on all functions |
explicit-table-ids | Table name in db operations |
import-wrong-runtime | No Node imports in Convex runtime |
Docs: https://docs.convex.dev/eslint
Documentation Sources
Before implementing, do not assume; fetch the latest documentation:
- Primary: https://docs.convex.dev/understanding/best-practices/
- Error Handling: https://docs.convex.dev/functions/error-handling
- Write Conflicts: https://docs.convex.dev/error#1
- For broader context: https://docs.convex.dev/llms.txt
Instructions
The Zen of Convex
- Convex manages the hard parts - Let Convex handle caching, real-time sync, and consistency
- Functions are the API - Design your functions as your application's interface
- Schema is truth - Define your data model explicitly in schema.ts
- TypeScript everywhere - Leverage end-to-end type safety
- Queries are reactive - Think in terms of subscriptions, not requests
Function Organization
Organize your Convex functions by domain:
// convex/users.ts - User-related functions
import { query, mutation } from "./_generated/server";
import { v } from "convex/values";
export const get = query({
args: { userId: v.id("users") },
returns: v.union(
v.object({
_id: v.id("users"),
_creationTime: v.number(),
name: v.string(),
email: v.string(),
}),
v.null(),
),
handler: async (ctx, args) => {
return await ctx.db.get("users", args.userId);
},
});
Argument and Return Validation
Always define validators for arguments AND return types:
export const createTask = mutation({
args: {
title: v.string(),
description: v.optional(v.string()),
priority: v.union(v.literal("low"), v.literal("medium"), v.literal("high")),
},
returns: v.id("tasks"),
handler: async (ctx, args) => {
return await ctx.db.insert("tasks", {
title: args.title,
description: args.description,
priority: args.priority,
completed: false,
createdAt: Date.now(),
});
},
});
Query Patterns
Use indexes instead of filters for efficient queries:
// Schema with index
export default defineSchema({
tasks: defineTable({
userId: v.id("users"),
status: v.string(),
createdAt: v.number(),
})
.index("by_user", ["userId"])
.index("by_user_and_status", ["userId", "status"]),
});
// Query using index
export const getTasksByUser = query({
args: { userId: v.id("users") },
returns: v.array(
v.object({
_id: v.id("tasks"),
_creationTime: v.number(),
userId: v.id("users"),
status: v.string(),
createdAt: v.number(),
}),
),
handler: async (ctx, args) => {
return await ctx.db
.query("tasks")
.withIndex("by_user", (q) => q.eq("userId", args.userId))
.order("desc")
.collect();
},
});
Error Handling
Use ConvexError for user-facing errors:
import { ConvexError } from "convex/values";
export const updateTask = mutation({
args: {
taskId: v.id("tasks"),
title: v.string(),
},
returns: v.null(),
handler: async (ctx, args) => {
const task = await ctx.db.get("tasks", args.taskId);
if (!task) {
throw new ConvexError({
code: "NOT_FOUND",
message: "Task not found",
});
}
await ctx.db.patch("tasks", args.taskId, { title: args.title });
return null;
},
});
Avoiding Write Conflicts (Optimistic Concurrency Control)
Convex uses OCC. Follow these patterns to minimize conflicts:
// GOOD: Make mutations idempotent
export const completeTask = mutation({
args: { taskId: v.id("tasks") },
returns: v.null(),
handler: async (ctx, args) => {
const task = await ctx.db.get("tasks", args.taskId);
// Early return if already complete (idempotent)
if (!task || task.status === "completed") {
return null;
}
await ctx.db.patch("tasks", args.taskId, {
status: "completed",
completedAt: Date.now(),
});
return null;
},
});
// GOOD: Patch directly without reading first when possible
export const updateNote = mutation({
args: { id: v.id("notes"), content: v.string() },
returns: v.null(),
handler: async (ctx, args) => {
// Patch directly - ctx.db.patch throws if document doesn't exist
await ctx.db.patch("notes", args.id, { content: args.content });
return null;
},
});
// GOOD: Use Promise.all for parallel independent updates
export const reorderItems = mutation({
args: { itemIds: v.array(v.id("items")) },
returns: v.null(),
handler: async (ctx, args) => {
const updates = args.itemIds.map((id, index) =>
ctx.db.patch("items", id, { order: index }),
);
await Promise.all(updates);
return null;
},
});
TypeScript Best Practices
import { Id, Doc } from "./_generated/dataModel";
// Use Id type for document references
type UserId = Id<"users">;
// Use Doc type for full documents
type User = Doc<"users">;
// Define Record types properly
const userScores: Record<Id<"users">, number> = {};
Internal vs Public Functions
// Public function - exposed to clients
export const getUser = query({
args: { userId: v.id("users") },
returns: v.union(
v.null(),
v.object({
/* ... */
}),
),
handler: async (ctx, args) => {
// ...
},
});
// Internal function - only callable from other Convex functions
export const _updateUserStats = internalMutation({
args: { userId: v.id("users") },
returns: v.null(),
handler: async (ctx, args) => {
// ...
},
});
Examples
Complete CRUD Pattern
// convex/tasks.ts
import { query, mutation } from "./_generated/server";
import { v } from "convex/values";
import { ConvexError } from "convex/values";
const taskValidator = v.object({
_id: v.id("tasks"),
_creationTime: v.number(),
title: v.string(),
completed: v.boolean(),
userId: v.id("users"),
});
export const list = query({
args: { userId: v.id("users") },
returns: v.array(taskValidator),
handler: async (ctx, args) => {
return await ctx.db
.query("tasks")
.withIndex("by_user", (q) => q.eq("userId", args.userId))
.collect();
},
});
export const create = mutation({
args: {
title: v.string(),
userId: v.id("users"),
},
returns: v.id("tasks"),
handler: async (ctx, args) => {
return await ctx.db.insert("tasks", {
title: args.title,
completed: false,
userId: args.userId,
});
},
});
export const update = mutation({
args: {
taskId: v.id("tasks"),
title: v.optional(v.string()),
completed: v.optional(v.boolean()),
},
returns: v.null(),
handler: async (ctx, args) => {
const { taskId, ...updates } = args;
// Remove undefined values
const cleanUpdates = Object.fromEntries(
Object.entries(updates).filter(([_, v]) => v !== undefined),
);
if (Object.keys(cleanUpdates).length > 0) {
await ctx.db.patch("tasks", taskId, cleanUpdates);
}
return null;
},
});
export const remove = mutation({
args: { taskId: v.id("tasks") },
returns: v.null(),
handler: async (ctx, args) => {
await ctx.db.delete("tasks", args.taskId);
return null;
},
});
Best Practices
- Never run
npx convex deployunless explicitly instructed - Never run any git commands unless explicitly instructed
- Always define return validators for functions
- Use indexes for all queries that filter data
- Make mutations idempotent to handle retries gracefully
- Use ConvexError for user-facing error messages
- Organize functions by domain (users.ts, tasks.ts, etc.)
- Use internal functions for sensitive operations
- Leverage TypeScript's Id and Doc types
Common Pitfalls
- Using filter instead of withIndex - Always define indexes and use withIndex
- Missing return validators - Always specify the returns field
- Non-idempotent mutations - Check current state before updating
- Reading before patching unnecessarily - Patch directly when possible
- Not handling null returns - Document IDs might not exist
References
- Convex Documentation: https://docs.convex.dev/
- Convex LLMs.txt: https://docs.convex.dev/llms.txt
- Best Practices: https://docs.convex.dev/understanding/best-practices/
- Error Handling: https://docs.convex.dev/functions/error-handling
- Write Conflicts: https://docs.convex.dev/error#1
Related skills
More from waynesutton/convexskills and the wider catalog.
convex
Umbrella skill routing to specialized Convex development patterns and tools.
convex-functions
Write Convex queries, mutations, actions, and HTTP endpoints with validation, error handling, and best practices.
convex-schema-validator
Define and validate Convex database schemas with typing, indexes, and migration strategies.
convex-realtime
Build reactive apps with Convex subscriptions, optimistic updates, and cursor-based pagination
convex-cron-jobs
Schedule recurring background tasks in Convex with interval and cron expression patterns.
convex-http-actions
Build HTTP endpoints for webhooks, external APIs, and custom routes in Convex.