Convex provides a TypeScript serverless backend with automatic real-time synchronization. All backend logic is defined in the convex/ directory.
Architecture Overview
Convex functions are organized into three types:
| Type | Use Case | Example |
|---|
| Query | Read data (GET) | getTools, getToolBySlug |
| Mutation | Write data (POST/PUT/DELETE) | submitTool, upvoteTool |
| Action | External API calls, AI, emails | sendApprovalEmail |
Queries
Queries are reactive - they automatically re-run when data changes.
Basic Query Pattern
export const getTools = query({
args: {
category: v.optional(v.string()),
pricing: v.optional(v.string()),
search: v.optional(v.string()),
sort: v.optional(v.string()), // "newest", "upvotes"
},
handler: async (ctx: QueryCtx, args) => {
let toolsQuery;
// Start with an index
if (args.category && args.category !== "All") {
toolsQuery = ctx.db
.query("tools")
.withIndex("by_category", (q) => q.eq("category", args.category!));
} else {
toolsQuery = ctx.db
.query("tools")
.withIndex("by_approved", (q) => q.eq("approved", true));
}
let tools = await toolsQuery.collect();
// Apply additional filters
if (args.category && args.category !== "All") {
tools = tools.filter((t) => t.approved);
}
if (args.pricing && args.pricing !== "All") {
tools = tools.filter((t) => t.pricing === args.pricing);
}
if (args.search) {
const searchLower = args.search.toLowerCase();
tools = tools.filter(
(t) =>
t.name.toLowerCase().includes(searchLower) ||
t.description.toLowerCase().includes(searchLower) ||
t.tags.some((tag) => tag.toLowerCase().includes(searchLower))
);
}
// Sort results
if (args.sort === "upvotes") {
tools.sort((a, b) => b.upvotes - a.upvotes);
} else {
tools.sort((a, b) => b.createdAt - a.createdAt);
}
return tools;
},
});
Always start queries with an index using .withIndex(). Convex requires indexed queries for performance.
Query by Unique Field
export const getToolBySlug = query({
args: { slug: v.string() },
handler: async (ctx: QueryCtx, args: { slug: string }) => {
return await ctx.db
.query("tools")
.withIndex("by_slug", (q) => q.eq("slug", args.slug))
.first(); // Returns first match or null
},
});
Query with Authentication
export const getSubmittedTools = query({
args: {},
handler: async (ctx: QueryCtx) => {
const identity = await getIdentity(ctx);
return await ctx.db
.query("tools")
.withIndex("by_submittedBy", (q) => q.eq("submittedBy", identity.subject))
.collect();
},
});
Using Queries in React
import { useQuery } from "convex/react";
import { api } from "@/convex/_generated/api";
function ToolsPage() {
const tools = useQuery(api.tools.getTools, {
category: "Image Generation",
sort: "upvotes"
});
if (tools === undefined) return <div>Loading...</div>;
return (
<div>
{tools.map(tool => <ToolCard key={tool._id} tool={tool} />)}
</div>
);
}
Queries return undefined while loading, then update automatically when data changes. No need for manual refetching!
Mutations
Mutations modify database state.
Create (Insert)
export const submitTool = mutation({
args: {
name: v.string(),
description: v.string(),
category: v.string(),
tags: v.array(v.string()),
websiteUrl: v.string(),
pricing: v.string(),
// ... more fields
},
handler: async (ctx: MutationCtx, args: any) => {
const identity = await getIdentity(ctx);
// Generate slug
const slug = args.name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)+/g, "");
// Check for conflicts
const existing = await ctx.db
.query("tools")
.withIndex("by_slug", (q) => q.eq("slug", slug))
.first();
const finalSlug = existing ? `${slug}-${Date.now()}` : slug;
// Insert new tool
const toolId = await ctx.db.insert("tools", {
name: args.name,
slug: finalSlug,
description: args.description,
category: args.category,
tags: args.tags,
websiteUrl: args.websiteUrl,
pricing: args.pricing,
upvotes: 0,
submittedBy: identity.subject,
approved: false,
createdAt: Date.now(),
isNew: true,
// ... more fields
});
return { toolId, slug: finalSlug };
},
});
Update (Patch)
export const approveTool = mutation({
args: {
toolId: v.id("tools"),
sendEmail: v.optional(v.boolean()),
},
handler: async (ctx: MutationCtx, args: { toolId: Id<"tools"> }) => {
await checkAdmin(ctx);
await ctx.db.patch(args.toolId, { approved: true });
return { success: true };
},
});
Delete
export const rejectTool = mutation({
args: {
toolId: v.id("tools"),
reason: v.optional(v.string()),
},
handler: async (ctx: MutationCtx, args) => {
await checkAdmin(ctx);
// Get data before deletion (for emails, etc.)
const tool = await ctx.db.get(args.toolId);
await ctx.db.delete(args.toolId);
return { success: true, tool, reason: args.reason };
},
});
Increment Counter
export const upvoteTool = mutation({
args: { toolId: v.id("tools") },
handler: async (ctx: MutationCtx, args: { toolId: Id<"tools"> }) => {
const tool = await ctx.db.get(args.toolId);
if (!tool) throw new Error("Tool not found");
await ctx.db.patch(args.toolId, { upvotes: tool.upvotes + 1 });
},
});
Using Mutations in React
import { useMutation } from "convex/react";
import { api } from "@/convex/_generated/api";
function UpvoteButton({ toolId }: { toolId: Id<"tools"> }) {
const upvoteTool = useMutation(api.tools.upvoteTool);
return (
<button onClick={() => upvoteTool({ toolId })}>
Upvote
</button>
);
}
Authentication & Authorization
Convex integrates with Clerk for user identity.
Get Current User
async function getIdentity(ctx: QueryCtx | MutationCtx) {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Unauthenticated");
return identity;
}
Admin Check
const getAdminIds = () => (process.env.NEXT_PUBLIC_ADMIN_USER_IDS || "")
.split(",")
.map((id) => id.trim())
.filter(Boolean);
async function checkAdmin(ctx: QueryCtx | MutationCtx) {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Unauthenticated");
if (!getAdminIds().includes(identity.subject)) {
throw new Error("Unauthorized: Admin access required");
}
return identity;
}
Protected Query
export const getPendingTools = query({
handler: async (ctx: QueryCtx) => {
await checkAdmin(ctx); // Throws if not admin
return await ctx.db
.query("tools")
.withIndex("by_approved", (q) => q.eq("approved", false))
.collect();
},
});
Always verify user identity in mutations. Never trust client-side authentication checks.
Real-Time Subscriptions
Convex queries are reactive - components automatically re-render when data changes.
// Component A: Displays tools
function ToolsList() {
const tools = useQuery(api.tools.getTools, {});
// Automatically updates when tools change
return tools?.map(tool => <div>{tool.name}</div>);
}
// Component B: Upvotes a tool
function UpvoteButton({ toolId }) {
const upvote = useMutation(api.tools.upvoteTool);
return <button onClick={() => upvote({ toolId })}>Upvote</button>;
}
When the upvote button is clicked:
upvoteTool mutation runs
- Database is updated
- All components using
getTools automatically re-render with new data
No manual cache invalidation or refetching needed - Convex handles it automatically!
Aggregations & Statistics
export const getStats = query({
handler: async (ctx: QueryCtx) => {
const approvedTools = await ctx.db
.query("tools")
.withIndex("by_approved", (q) => q.eq("approved", true))
.collect();
const categories = new Set(approvedTools.map((t) => t.category));
const totalUpvotes = approvedTools.reduce((sum, t) => sum + t.upvotes, 0);
const featuredCount = approvedTools.filter((t) => t.featured).length;
return {
totalTools: approvedTools.length,
totalCategories: categories.size,
totalFeatured: featuredCount,
totalUpvotes,
};
},
});
Error Handling
export const getToolBySlug = query({
args: { slug: v.string() },
handler: async (ctx, args) => {
const tool = await ctx.db
.query("tools")
.withIndex("by_slug", (q) => q.eq("slug", args.slug))
.first();
if (!tool) {
throw new Error("Tool not found");
}
if (!tool.approved) {
throw new Error("Tool pending approval");
}
return tool;
},
});
Handle errors in React:
function ToolPage({ slug }) {
const tool = useQuery(api.tools.getToolBySlug, { slug });
if (tool === undefined) return <div>Loading...</div>;
if (tool === null) return <div>Tool not found</div>;
return <div>{tool.name}</div>;
}
Best Practices
Start queries with indices
Always use .withIndex() as the first step in a query for performance.// ✓ Good
ctx.db.query("tools").withIndex("by_category", q => q.eq("category", cat))
// ✗ Bad - table scan
ctx.db.query("tools").filter(q => q.eq(q.field("category"), cat))
Queries should only read data, never modify it. Mutations should modify data.
For better UX, update UI immediately before mutation completes:const upvote = useMutation(api.tools.upvoteTool)
.withOptimisticUpdate((localStore, args) => {
const tool = localStore.getQuery(api.tools.getToolById, { toolId: args.toolId });
if (tool) localStore.setQuery(api.tools.getToolById, { toolId: args.toolId }, {
...tool,
upvotes: tool.upvotes + 1
});
});
Next Steps
Database Schema
Understand table structures and relationships
Authentication
Learn about Clerk + Convex auth integration