Convex is a reactive backend platform: define schema, queries, and mutations in TypeScript, and React components subscribe to live data with useQuery — updates flow automatically when the database changes. Queries are deterministic functions that read from the database. Mutations transactionally write data. Actions call external APIs and schedule work. The schema enforces types at runtime. Every query and mutation is an isolated transaction on Convex’s built-in document database. Claude Code generates Convex schema definitions, query and mutation functions, React hooks integration, scheduled actions, and the file storage patterns for production Convex applications.
CLAUDE.md for Convex Projects
## Convex Stack
- Version: convex >= 1.15
- Schema: define in convex/schema.ts with v.* validators — required for all tables
- Functions: query (read), mutation (write), action (external I/O) — all server-side TypeScript
- React: useQuery() for live data, useMutation() for writes, useAction() for actions
- Auth: convex-auth with GitHub/Google/password, or custom JWT via middleware
- Files: storage.generateUploadUrl() + storage.getUrl() + ctx.storage.store()
- Scheduled: api.myModule.myAction via ctx.scheduler.runAfter/runAt
Schema Definition
// convex/schema.ts — type-safe database schema
import { defineSchema, defineTable } from "convex/server"
import { v } from "convex/values"
export default defineSchema({
orders: defineTable({
customerId: v.string(),
status: v.union(
v.literal("pending"),
v.literal("processing"),
v.literal("shipped"),
v.literal("delivered"),
v.literal("cancelled"),
),
totalCents: v.number(),
items: v.array(v.object({
productId: v.string(),
productName: v.string(),
quantity: v.number(),
priceCents: v.number(),
})),
shippingAddress: v.optional(v.object({
line1: v.string(),
city: v.string(),
country: v.string(),
postalCode: v.string(),
})),
trackingNumber: v.optional(v.string()),
createdBy: v.id("users"),
})
.index("by_customer", ["customerId"])
.index("by_status", ["status"])
.searchIndex("search_orders", {
searchField: "customerId",
filterFields: ["status"],
}),
users: defineTable({
name: v.string(),
email: v.string(),
role: v.union(v.literal("customer"), v.literal("admin")),
tokenIdentifier: v.string(),
}).index("by_token", ["tokenIdentifier"]),
})
Query Functions
// convex/orders.ts — queries, mutations, and actions
import { query, mutation, action } from "./_generated/server"
import { v } from "convex/values"
import { paginationOptsValidator } from "convex/server"
// Query: read-only, auto-subscribes in React
export const listOrders = query({
args: {
status: v.optional(v.string()),
paginationOpts: paginationOptsValidator,
},
handler: async (ctx, args) => {
const identity = await ctx.auth.getUserIdentity()
if (!identity) throw new Error("Unauthorized")
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")
let ordersQuery = ctx.db
.query("orders")
.withIndex("by_customer", (q) => q.eq("customerId", user._id))
if (args.status) {
ordersQuery = ctx.db
.query("orders")
.withIndex("by_status", (q) => q.eq("status", args.status!))
}
return await ordersQuery
.order("desc")
.paginate(args.paginationOpts)
},
})
// Get single order
export const getOrder = query({
args: { orderId: v.id("orders") },
handler: async (ctx, args) => {
const identity = await ctx.auth.getUserIdentity()
if (!identity) throw new Error("Unauthorized")
const order = await ctx.db.get(args.orderId)
if (!order) throw new Error("Order not found")
return order
},
})
// Admin query: all orders with customer details
export const adminListOrders = query({
args: {
status: v.optional(v.string()),
limit: v.optional(v.number()),
},
handler: async (ctx, args) => {
const identity = await ctx.auth.getUserIdentity()
if (!identity?.tokenIdentifier) throw new Error("Unauthorized")
const user = await ctx.db
.query("users")
.withIndex("by_token", (q) => q.eq("tokenIdentifier", identity.tokenIdentifier))
.unique()
if (user?.role !== "admin") throw new Error("Forbidden")
const limit = args.limit ?? 50
let ordersQuery = ctx.db.query("orders").order("desc")
const orders = await ordersQuery.take(limit)
// Eager load customers
const customerIds = [...new Set(orders.map((o) => o.customerId))]
const customers = await Promise.all(customerIds.map((id) => ctx.db.get(id as any)))
const customerMap = Object.fromEntries(
customers.filter(Boolean).map((c) => [c!._id, c])
)
return orders.map((order) => ({
...order,
customer: customerMap[order.customerId],
}))
},
})
Mutation Functions
// Mutation: transactional write
export const createOrder = mutation({
args: {
items: v.array(v.object({
productId: v.string(),
productName: v.string(),
quantity: v.number(),
priceCents: v.number(),
})),
shippingAddress: v.object({
line1: v.string(),
city: v.string(),
country: v.string(),
postalCode: v.string(),
}),
},
handler: async (ctx, args) => {
const identity = await ctx.auth.getUserIdentity()
if (!identity) throw new Error("Unauthorized")
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")
const totalCents = args.items.reduce(
(sum, item) => sum + item.priceCents * item.quantity,
0
)
const orderId = await ctx.db.insert("orders", {
customerId: user._id,
status: "pending",
totalCents,
items: args.items,
shippingAddress: args.shippingAddress,
createdBy: user._id,
})
// Schedule fulfillment action after 1 second
await ctx.scheduler.runAfter(1000, api.orders.startFulfillment, {
orderId,
})
return orderId
},
})
export const cancelOrder = mutation({
args: { orderId: v.id("orders") },
handler: async (ctx, args) => {
const order = await ctx.db.get(args.orderId)
if (!order) throw new Error("Order not found")
if (order.status !== "pending") {
throw new Error(`Cannot cancel order with status: ${order.status}`)
}
await ctx.db.patch(args.orderId, { status: "cancelled" })
return args.orderId
},
})
Action Functions and Scheduling
// Actions: can call external APIs, run side effects
import { action, internalMutation } from "./_generated/server"
import { api, internal } from "./_generated/api"
export const startFulfillment = action({
args: { orderId: v.id("orders") },
handler: async (ctx, args) => {
// Call external fulfillment API
const response = await fetch("https://fulfillment.api.com/orders", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${process.env.FULFILLMENT_API_KEY}`,
},
body: JSON.stringify({ orderId: args.orderId }),
})
if (!response.ok) {
throw new Error(`Fulfillment API error: ${response.status}`)
}
const { fulfillmentId } = await response.json()
// Update database via internal mutation
await ctx.runMutation(internal.orders.updateFulfillmentId, {
orderId: args.orderId,
fulfillmentId,
})
},
})
// Scheduled function: daily revenue report
export const sendDailyReport = action({
args: {},
handler: async (ctx) => {
const orders = await ctx.runQuery(api.orders.adminListOrders, {
status: "delivered",
limit: 1000,
})
const revenue = orders.reduce((sum: number, o: any) => sum + o.totalCents, 0)
await sendSlackMessage(`Daily Revenue: $${(revenue / 100).toFixed(2)} from ${orders.length} orders`)
},
})
React Integration
// src/components/OrderList.tsx — live-updating React with Convex
import { useQuery, useMutation } from "convex/react"
import { api } from "../convex/_generated/api"
import { usePaginatedQuery } from "convex/react"
export function OrderList() {
// useQuery: subscribes to real-time updates
const { results: orders, loadMore, status } = usePaginatedQuery(
api.orders.listOrders,
{}, // args
{ initialNumItems: 20 },
)
const cancelOrder = useMutation(api.orders.cancelOrder)
const handleCancel = async (orderId: string) => {
try {
await cancelOrder({ orderId: orderId as any })
} catch (e) {
alert(`Failed to cancel: ${(e as Error).message}`)
}
}
if (status === "LoadingFirstPage") return <div>Loading...</div>
return (
<div className="space-y-3">
{orders.map((order) => (
<div key={order._id} className="border rounded-lg p-4">
<div className="flex justify-between">
<div>
<p className="font-mono text-sm">{order._id}</p>
<p className="font-bold">${(order.totalCents / 100).toFixed(2)}</p>
</div>
<div className="flex items-center gap-2">
<span className="text-sm">{order.status}</span>
{order.status === "pending" && (
<button
onClick={() => handleCancel(order._id)}
className="text-red-500 text-sm"
>
Cancel
</button>
)}
</div>
</div>
</div>
))}
{status === "CanLoadMore" && (
<button onClick={() => loadMore(20)}>Load more</button>
)}
</div>
)
}
For the Supabase alternative with PostgreSQL + Row Level Security + realtime subscriptions instead of Convex’s document database, see the patterns in the authentication guide for Supabase Auth. For the tRPC + Prisma stack that provides similar TypeScript end-to-end type safety with your own PostgreSQL database, the Prisma advanced guide covers extensions and transactions. The Claude Skills 360 bundle includes Convex skill sets covering schema design, query/mutation patterns, and React integration. Start with the free tier to try Convex backend generation.