Convex Best Practices (2026)
Core Principles
- •Thin API Layer -
convex/game.tsdelegates to component clients - •Internal Functions - Use
internalQuery/internalMutationfor composition - •Index-First - Always use indexes, never
.filter()on DB queries - •Type Safety - Use
returnsvalidators for runtime validation - •Components - White-label components own their schemas
Function Patterns
Query
typescript
export const myQuery = query({
args: { userId: v.string() },
handler: async (ctx, args) => {
const user = await getUser(ctx); // Auth check
return await ltcgCards.cards.getUserCards(ctx, { userId: user._id });
},
});
Mutation
typescript
export const myMutation = mutation({
args: { deckId: v.string(), name: v.string() },
handler: async (ctx, args) => {
const user = await getUser(ctx);
await ltcgCards.decks.saveDeck(ctx, { deckId: args.deckId, name: args.name });
},
});
Scheduled (AI turns)
typescript
export const executeAITurn = internalMutation({
args: { matchId: v.string() },
handler: async (ctx, { matchId }) => {
// Load state, compute legalMoves, pick best, submit
},
});
// Schedule from another mutation:
await ctx.scheduler.runAfter(500, internal.game.executeAITurn, { matchId });
Indexing Rules
typescript
// ALWAYS use indexes
const results = await ctx.db
.query("users")
.withIndex("by_privyId", (q) => q.eq("privyId", privyId))
.unique();
// NEVER use .filter()
// BAD: const users = await ctx.db.query("users").filter(q => q.eq(q.field("privyId"), id)).collect();
Composite indexes serve prefix queries
typescript
// One index handles both queries:
.index("by_team_and_user", ["team", "user"])
// All team members:
.withIndex("by_team_and_user", (q) => q.eq("team", teamId))
// Specific member:
.withIndex("by_team_and_user", (q) => q.eq("team", teamId).eq("user", userId))
Auth Pattern (Privy)
typescript
// convex/auth.ts
export const syncUser = mutation({
args: { privyId: v.string(), email: v.optional(v.string()) },
handler: async (ctx, args) => {
const existing = await ctx.db
.query("users")
.withIndex("by_privyId", (q) => q.eq("privyId", args.privyId))
.unique();
if (existing) {
await ctx.db.patch(existing._id, { ...args });
return existing._id;
}
return await ctx.db.insert("users", { ...args, createdAt: Date.now() });
},
});
// Helper used by all functions
async function getUser(ctx: QueryCtx | MutationCtx) {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Unauthorized");
const user = await ctx.db
.query("users")
.withIndex("by_privyId", (q) => q.eq("privyId", identity.subject))
.unique();
if (!user) throw new Error("User not found");
return user;
}
TS2589 Prevention
typescript
// ALWAYS extract inline objects
const opts = { namespace: id, bounds: {} };
return aggregate.count(ctx, opts);
// NEVER inline
// return aggregate.count(ctx, { namespace: id, bounds: {} }); // TS2589!
// Use convexHelpers in frontend
import { apiAny, useConvexMutation } from "@/lib/convexHelpers";
Testing with convex-test
typescript
import { convexTest } from "convex-test";
import { describe, it, expect } from "vitest";
import schema from "./schema";
describe("game", () => {
it("creates a deck", async () => {
const t = convexTest(schema);
const userId = await t.run(async (ctx) => {
return await ctx.db.insert("users", { privyId: "test", createdAt: Date.now() });
});
t.withIdentity({ subject: "test" });
const result = await t.mutation(api.game.createDeck, { name: "Test" });
expect(result).toBeDefined();
});
});
Common Pitfalls
- •No
.filter()- Always use indexes - •No
fetch()in queries/mutations - Use actions for external calls - •Always auth check - Every public function needs
getUser(ctx) - •Extract objects - Don't inline complex objects in component calls
- •Schema validation off - Currently disabled, don't rely on it