Discover and use convex-helpers utilities for relationships, filtering, sessions, custom functions, CRUD generation, validator utilities, cached queries, and more. Use when you need pre-built Convex patterns, typed validators (brandedString, literals, nullable, pick, omit, typedV), auto-generated CRUD, query caching, or useQueryWithStatus.
Use convex-helpers to add common patterns and utilities to your Convex backend without reinventing the wheel.
convex-helpers is the official collection of utilities that complement Convex. It provides battle-tested patterns for common backend needs.
Installation:
npm install convex-helpers
Traverse relationships between tables in a readable, type-safe way.
Use when:
Example:
import { getOneFrom, getManyFrom } from "convex-helpers/server/relationships";
export const getTaskWithUser = query({
args: { taskId: v.id("tasks") },
handler: async (ctx, args) => {
const task = await ctx.db.get(args.taskId);
if (!task) return null;
// Get related user
const user = await getOneFrom(ctx.db, "users", "by_id", task.userId, "_id");
// Get related comments
const comments = await getManyFrom(
ctx.db,
"comments",
"by_task",
task._id,
"taskId",
);
return { ...task, user, comments };
},
});
Key Functions:
getOneFrom - Get single related documentgetManyFrom - Get multiple related documentsgetManyVia - Get many-to-many relationships through junction tableThis is Convex's alternative to Row Level Security (RLS). Instead of database-level policies, use custom function wrappers to automatically add auth and access control to all queries and mutations.
Create wrapped versions of query/mutation/action with custom behavior.
Use when:
Why this instead of RLS:
Example: Custom Query with Auto-Auth
// convex/lib/customFunctions.ts
import { customQuery } from "convex-helpers/server/customFunctions";
import { query } from "../_generated/server";
export const authenticatedQuery = customQuery(query, {
args: {}, // No additional args required
input: async (ctx, args) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) {
throw new Error("Not authenticated");
}
const user = await ctx.db
.query("users")
.withIndex("by_token", (q) =>
q.eq("tokenIdentifier", identity.tokenIdentifier),
)
.unique();
if (!user) throw new Error("User not found");
// Add user to context
return { ctx: { ...ctx, user }, args };
},
});
// Usage in your functions
export const getMyTasks = authenticatedQuery({
handler: async (ctx) => {
// ctx.user is automatically available!
return await ctx.db
.query("tasks")
.withIndex("by_user", (q) => q.eq("userId", ctx.user._id))
.collect();
},
});
Example: Multi-Tenant Data Protection
import { customQuery } from "convex-helpers/server/customFunctions";
import { query } from "../_generated/server";
// Organization-scoped query - automatic access control
export const orgQuery = customQuery(query, {
args: { orgId: v.id("organizations") },
input: async (ctx, args) => {
const user = await getCurrentUser(ctx);
// Verify user is a member of this organization
const member = await ctx.db
.query("organizationMembers")
.withIndex("by_org_and_user", (q) =>
q.eq("orgId", args.orgId).eq("userId", user._id),
)
.unique();
if (!member) {
throw new Error("Not authorized for this organization");
}
// Inject org context
return {
ctx: {
...ctx,
user,
orgId: args.orgId,
role: member.role,
},
args,
};
},
});
// Usage - data automatically scoped to organization
export const getOrgProjects = orgQuery({
args: { orgId: v.id("organizations") },
handler: async (ctx) => {
// ctx.user and ctx.orgId automatically available and verified!
return await ctx.db
.query("projects")
.withIndex("by_org", (q) => q.eq("orgId", ctx.orgId))
.collect();
},
});
Example: Role-Based Access Control
import { customMutation } from "convex-helpers/server/customFunctions";
import { mutation } from "../_generated/server";
export const adminMutation = customMutation(mutation, {
args: {},
input: async (ctx, args) => {
const user = await getCurrentUser(ctx);
if (user.role !== "admin") {
throw new Error("Admin access required");
}
return { ctx: { ...ctx, user }, args };
},
});
// Usage - only admins can call this
export const deleteUser = adminMutation({
args: { userId: v.id("users") },
handler: async (ctx, args) => {
// Only admins reach this code
await ctx.db.delete(args.userId);
},
});
Apply complex TypeScript filters to database queries.
Use when:
Example:
import { filter } from "convex-helpers/server/filter";
export const getActiveTasks = query({
handler: async (ctx) => {
const now = Date.now();
const threeDaysAgo = now - 3 * 24 * 60 * 60 * 1000;
return await filter(
ctx.db.query("tasks"),
(task) =>
!task.completed &&
task.createdAt > threeDaysAgo &&
task.priority === "high",
).collect();
},
});
Note: Still prefer indexes when possible! Use filter for complex logic that can't be indexed.
Track users across requests even when not logged in.
Use when:
Server setup — create a session-aware query builder:
// convex/lib/sessions.ts
import { customQuery } from "convex-helpers/server/customFunctions";
import { SessionIdArg } from "convex-helpers/server/sessions";
import { query } from "../_generated/server";
export const queryWithSession = customQuery(query, {
args: SessionIdArg,
input: async (ctx, { sessionId }) => {
const anonymousUser = await getAnonUser(ctx, sessionId);
return { ctx: { ...ctx, anonymousUser }, args: {} };
},
});
Usage in functions:
export const mySessionQuery = queryWithSession({
args: { arg1: v.number() },
handler: async (ctx, args) => {
// ctx.anonymousUser is available
},
});
Client (React) — wrap app with SessionProvider:
import { SessionProvider } from "convex-helpers/react/sessions";
// In your app root:
<ConvexProvider client={convex}>
<SessionProvider>
<App />
</SessionProvider>
</ConvexProvider>
Client hooks:
import { useSessionQuery } from "convex-helpers/react/sessions";
const results = useSessionQuery(api.myModule.mySessionQuery, { arg1: 1 });
Use Zod schemas instead of Convex validators.
Use when:
Example:
import { zCustomQuery } from "convex-helpers/server/zod";
import { z } from "zod";
import { query } from "./_generated/server";
const argsSchema = z.object({
email: z.string().email(),
age: z.number().min(18).max(120),
});
export const createUser = zCustomQuery(query, {
args: argsSchema,
handler: async (ctx, args) => {
// args is typed from Zod schema
return await ctx.db.insert("users", args);
},
});
Note: Convex recommends using custom functions (see #2 above) as the primary data protection pattern. This RLS helper is an alternative approach that mimics traditional RLS by wrapping the database reader/writer.
Use when:
However, custom functions are usually better because:
Example:
import {
customCtx,
customMutation,
customQuery,
} from "convex-helpers/server/customFunctions";
import {
Rules,
RLSConfig,
wrapDatabaseReader,
wrapDatabaseWriter,
} from "convex-helpers/server/rowLevelSecurity";
import { DataModel } from "./_generated/dataModel";
import { mutation, query, QueryCtx } from "./_generated/server";
async function rlsRules(ctx: QueryCtx) {
const identity = await ctx.auth.getUserIdentity();
return {
users: {
read: async (_, user) => {
if (!identity && user.age < 18) return false;
return true;
},
insert: async (_, user) => {
return true;
},
modify: async (_, user) => {
if (!identity)
throw new Error("Must be authenticated to modify a user");
return user.tokenIdentifier === identity.tokenIdentifier;
},
},
} satisfies Rules<QueryCtx, DataModel>;
}
// By default, tables with no rule have `defaultPolicy` set to "allow".
const config: RLSConfig = { defaultPolicy: "deny" };
const queryWithRLS = customQuery(
query,
customCtx(async (ctx) => ({
db: wrapDatabaseReader(ctx, ctx.db, await rlsRules(ctx), config),
})),
);
const mutationWithRLS = customMutation(
mutation,
customCtx(async (ctx) => ({
db: wrapDatabaseWriter(ctx, ctx.db, await rlsRules(ctx), config),
})),
);
Rules structure: Each table gets read, insert, and modify async functions that return boolean. The wrapDatabaseReader/wrapDatabaseWriter functions intercept all DB operations and enforce these rules transparently.
Recommended instead: Custom functions (see #2 above) — more explicit and type-safe.
Run stateful online data migrations. This is a component (@convex-dev/migrations), not a convex-helpers function. See the convex-components skill for full details.
Execute code automatically when data changes. Triggers run atomically within the same mutation — they intercept insert, patch, replace, and delete operations.
Use when:
Setup — wrap mutations to enable triggers:
// convex/functions.ts
import {
mutation as rawMutation,
internalMutation as rawInternalMutation,
} from "./_generated/server";
import { DataModel } from "./_generated/dataModel";
import { Triggers } from "convex-helpers/server/triggers";
import {
customCtx,
customMutation,
} from "convex-helpers/server/customFunctions";
export const triggers = new Triggers<DataModel>();
export const mutation = customMutation(rawMutation, customCtx(triggers.wrapDB));
export const internalMutation = customMutation(
rawInternalMutation,
customCtx(triggers.wrapDB),
);
// IMPORTANT: Import trigger registrations AFTER exports
import "./triggers";
Register triggers — callback receives a change object:
// convex/triggers.ts
import { triggers } from "./functions";
triggers.register("tasks", async (ctx, change) => {
// change.id — Document ID
// change.operation — 'insert' | 'update' | 'delete'
// change.newDoc — New document state (present on insert, update)
// change.oldDoc — Old document state (present on update, delete)
if (change.operation === "insert") {
await ctx.db.insert("notifications", {
userId: change.newDoc.userId,
type: "task_created",
taskId: change.id,
});
}
});
// Auto-sync aggregates
triggers.register("tasks", taskAggregate.trigger());
Critical: All mutation files must import from ./functions, NOT _generated/server, or triggers won't fire.
Efficient O(log n) count, sum, min, max operations. This is a component (@convex-dev/aggregate), not a convex-helpers function. See the convex-components skill for full details.
Keep aggregates in sync automatically using triggers (see #8 above):
triggers.register("tasks", tasksByStatus.trigger());
Auto-generate create, read, update, and delete functions for any table. Useful for internal tools, admin panels, and prototyping — but not recommended for production without access control wrappers around it.
Use when:
Example:
// convex/users.ts
import { crud } from "convex-helpers/server/crud";
import schema from "./schema.js";
export const { create, read, update, destroy } = crud(schema, "users");
// In an action:
const user = await ctx.runQuery(internal.users.read, { id: userId });
await ctx.runMutation(internal.users.update, {
id: userId,
patch: { status: "inactive" },
});
The generated functions are typed from your schema — create accepts the table's fields, update takes id + patch, read and destroy take id. For production, wrap these with custom functions (see #2) to add auth.
A rich set of utilities for building, composing, and validating Convex validators beyond the basic v.* primitives. These help you write stricter schemas, reuse validator fragments, and validate data at runtime.
Use when:
Schema-level utilities — import from convex-helpers/validators:
// convex/schema.ts
import {
literals,
deprecated,
brandedString,
nullable,
} from "convex-helpers/validators";
import { defineSchema, defineTable } from "convex/server";
import { v, Infer } from "convex/values";
export const emailValidator = brandedString("email");
export type Email = Infer<typeof emailValidator>;
export default defineSchema({
accounts: defineTable({
balance: nullable(v.bigint()), // v.union(v.bigint(), v.null())
status: literals("active", "inactive"), // v.union(v.literal("active"), v.literal("inactive"))
email: emailValidator, // typed string brand
oldField: deprecated, // accepts any value, signals "stop using this"
}).index("status", ["status"]),
});
Function-level utilities — typedV, doc, partial, pick, omit, validate:
import { doc, typedV, partial } from "convex-helpers/validators";
import { omit, pick } from "convex-helpers";
import schema from "./schema";
// Schema-aware validator builder — gives you vv.id("accounts"), vv.doc("accounts"), etc.
const vv = typedV(schema);
export const replaceUser = internalMutation({
args: {
id: vv.id("accounts"),
replace: vv.object({
...schema.tables.accounts.validator.fields,
...partial(systemFields("accounts")),
}),
},
returns: doc(schema, "accounts"),
handler: async (ctx, args) => {
await ctx.db.replace(args.id, args.replace);
return await ctx.db.get(args.id);
},
});
// Pick specific fields from a table's validator
const balanceAndEmail = pick(vv.doc("accounts").fields, ["balance", "email"]);
// Omit fields
const accountWithoutBalance = omit(vv.doc("accounts").fields, ["balance"]);
// Runtime validation — useful in actions before inserting
validate(balanceAndEmail, value); // returns boolean
validate(balanceAndEmail, value, { throw: true }); // throws ValidationError
validate(vv.id("accounts"), id, { db: ctx.db }); // validates ID exists in table
Key functions:
brandedString(name) — typed string brand (e.g., emails, slugs)literals(...values) — union of string/number literals (enum-like)nullable(validator) — shorthand for v.union(validator, v.null())deprecated — marks a field as deprecated in schemapartial(fields) — makes all fields optionaltypedV(schema) — schema-aware validator builder with .id(), .doc(), .object()doc(schema, table) — full document validator including system fieldspick(fields, keys) / omit(fields, keys) — subset/exclude fieldsvalidate(validator, value, opts?) — runtime validation with optional throwDrop-in replacement for Convex's useQuery that caches results client-side. When a component unmounts and remounts, it gets the cached value instantly instead of showing a loading state. Useful for tabs, navigation, and any UI where users revisit the same data.
Use when:
Setup — wrap your app with the cache provider:
import { ConvexQueryCacheProvider } from "convex-helpers/react/cache";
// For Next.js: import from "convex-helpers/react/cache/provider";
export default function App({ children }) {
return (
<ConvexClientProvider>
<ConvexQueryCacheProvider>
{children}
</ConvexQueryCacheProvider>
</ConvexClientProvider>
);
}
Usage — swap your import:
// Before:
// import { useQuery } from "convex/react";
// After:
import { useQuery } from "convex-helpers/react/cache";
// For Next.js: import from "convex-helpers/react/cache/hooks";
const users = useQuery(api.users.list);
Also provides cached versions of useQueries and usePaginatedQuery from the same module. Default cache expiration is 5 minutes, max 250 idle entries.
Richer alternative to the standard useQuery that returns explicit status fields instead of undefined for loading and throwing on errors. Makes it easier to build UIs with proper loading/error states without try-catch.
Use when:
useQuery returns undefined for both)Setup — create the hook once:
// lib/hooks.ts
import { makeUseQueryWithStatus } from "convex-helpers/react";
import { useQueries } from "convex/react";
export const useQueryWithStatus = makeUseQueryWithStatus(useQueries);
Usage:
import { useQueryWithStatus } from "@/lib/hooks";
function TaskList() {
const { status, data, error, isSuccess, isPending, isError } =
useQueryWithStatus(api.tasks.list, { userId: "123" });
if (isPending) return <Spinner />;
if (isError) return <ErrorMessage error={error} />;
return <TaskTable tasks={data} />;
}
Returns { status, data, error, isSuccess, isPending, isError } — similar to TanStack Query's API shape, so it feels familiar if you've used that.
import { customQuery } from "convex-helpers/server/customFunctions";
export const authedQuery = customQuery(query, {
args: {},
input: async (ctx, args) => {
const user = await getCurrentUser(ctx);
return { ctx: { ...ctx, user }, args };
},
});
// Now all queries automatically have user in context
export const getMyData = authedQuery({
handler: async (ctx) => {
// ctx.user is typed and available!
return await ctx.db
.query("data")
.withIndex("by_user", (q) => q.eq("userId", ctx.user._id))
.collect();
},
});
import { getOneFrom, getManyFrom } from "convex-helpers/server/relationships";
export const getPostWithDetails = query({
args: { postId: v.id("posts") },
handler: async (ctx, args) => {
const post = await ctx.db.get(args.postId);
if (!post) return null;
// Load author
const author = await getOneFrom(
ctx.db,
"users",
"by_id",
post.authorId,
"_id",
);
// Load comments
const comments = await getManyFrom(
ctx.db,
"comments",
"by_post",
post._id,
"postId",
);
// Load tags (many-to-many)
const tagLinks = await getManyFrom(
ctx.db,
"postTags",
"by_post",
post._id,
"postId",
);
const tags = await Promise.all(
tagLinks.map((link) =>
getOneFrom(ctx.db, "tags", "by_id", link.tagId, "_id"),
),
);
return { ...post, author, comments, tags };
},
});
import { asyncMap } from "convex-helpers";
export const batchUpdateTasks = mutation({
args: {
taskIds: v.array(v.id("tasks")),
status: v.string(),
},
handler: async (ctx, args) => {
const results = await asyncMap(args.taskIds, async (taskId) => {
try {
const task = await ctx.db.get(taskId);
if (task) {
await ctx.db.patch(taskId, { status: args.status });
return { success: true, taskId };
}
return { success: false, taskId, error: "Not found" };
} catch (error) {
return { success: false, taskId, error: error.message };
}
});
return results;
},
});
Start with convex-helpers
Custom Functions for Auth
authedQuery, authedMutation, etc.Relationships Over Nesting
Filter Sparingly
Sessions for Anonymous Users
| Need | Use | Import From |
|---|---|---|
| Load related data | getOneFrom, getManyFrom | convex-helpers/server/relationships |
| Auth in all functions | customQuery | convex-helpers/server/customFunctions |
| Complex filters | filter | convex-helpers/server/filter |
| Anonymous users | useSessionId | convex-helpers/react/sessions |
| Zod validation | zCustomQuery | convex-helpers/server/zod |
| Data migrations | Migrations | @convex-dev/migrations (component) |
| Triggers | Triggers | convex-helpers/server/triggers (requires customMutation wrapper) |
| Auto CRUD | crud | convex-helpers/server/crud |
| Typed validators | brandedString, literals, nullable | convex-helpers/validators |
| Schema-aware validators | typedV, doc, partial | convex-helpers/validators |
| Pick/omit fields | pick, omit | convex-helpers |
| Runtime validation | validate | convex-helpers/validators |
| Cached queries | useQuery (cached) | convex-helpers/react/cache |
| Query with status | makeUseQueryWithStatus | convex-helpers/react |
| Batch async ops | asyncMap | convex-helpers |