
Convex
- 9 installs
- 5 repo stars
- Updated June 3, 2026
- dimitrigilbert/ai-skills
Build full-stack TypeScript apps on Convex with queries, mutations, actions, schema, and real-time features.
About
Expert guidance for building full-stack TypeScript apps on Convex, the reactive database platform. A developer uses it for server functions, schema, auth, and frontend integration with Convex.
- Covers Convex queries, mutations, and actions with schema, indexes, and reactivity
- Guides setup, auth, file storage, real-time features, and frontend integration
Convex by the numbers
- 9 all-time installs (skills.sh)
- Ranked #3,606 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Jul 29, 2026 (Skillselion catalog sync)
npx skills add https://github.com/dimitrigilbert/ai-skills --skill convexAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 9 |
|---|---|
| repo stars | ★ 5 |
| Last updated | June 3, 2026 |
| Repository | dimitrigilbert/ai-skills ↗ |
What it does
Build full-stack TypeScript apps on Convex with queries, mutations, actions, schema, and real-time features.
Files
This skill provides expert guidance for building applications with Convex - the full-stack reactive database platform.
Quick Start
When a user wants to start with Convex:
1. New project:
npm create convex@latest
npx convex dev2. Add to existing project:
npm install convex
npx convex dev3. Verify: Check dashboard at https://dashboard.convex.dev
Core Concepts
Three Function Types
Queries - Read-only, auto-reactive:
import { query } from "./_generated/server";
export const list = query({
args: { channelId: v.id("channels") },
handler: async ({ db }, { channelId }) => {
return await db.query("messages")
.withIndex("by_channel", (q) => q.eq("channel", channelId))
.collect();
}
});Mutations - Write operations, transactions:
import { mutation } from "./_generated/server";
export const send = mutation({
args: { body: v.string(), channel: v.id("channels") },
handler: async ({ db }, { body, channel }) => {
await db.insert("messages", { body, channel, createdAt: Date.now() });
}
});Actions - External API calls:
import { action } from "./_generated/server";
export const summarize = action({
args: { postId: v.id("posts") },
handler: async (ctx, { postId }) => {
const post = await ctx.runQuery(api.posts.get, { postId });
const summary = await fetchLLM(post);
await ctx.runMutation(api.posts.update, { postId, summary });
}
});Database Operations
// CRUD
await db.insert("table", { field: value });
const doc = await db.get(id);
await db.patch(id, { field: newValue });
await db.delete(id);
// Query methods
.collect() // All results
.first() // First or null
.unique() // Exactly one (throws if not)
.paginate({ numItems: 50 }) // PaginationSchema Definition
File: convex/schema.ts
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export default defineSchema({
messages: defineTable({
body: v.string(),
author: v.id("users"),
channel: v.id("channels"),
createdAt: v.number()
})
.index("by_channel", ["channel"])
.index("by_channel_created", ["channel", "createdAt"])
});Common types:
v.string(),v.number(),v.boolean()v.id("table")- Foreign keyv.array(T),v.object({})v.optional(T),v.union(...)
Frontend Integration
React
import { ConvexProvider, ConvexReactClient } from "convex/react";
const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!);
<ConvexProvider client={convex}>
<App />
</ConvexProvider>Usage:
import { useQuery, useMutation } from "convex/react";
import { api } from "../convex/_generated/api";
const messages = useQuery(api.messages.list, { channelId });
const sendMessage = useMutation(api.messages.send);Next.js App Router
Server-side preloading:
import { preloadQuery } from "convex/nextjs";
const preloaded = await preloadQuery(api.posts.list);
return <ClientPage preloaded={preloaded} />;Client component:
"use client";
import { usePreloadedQuery } from "convex/react";
const posts = usePreloadedQuery(preloaded);Authentication
Clerk (Recommended)
Install: npm install @clerk/clerk-react
File: convex/auth.config.ts
import { AuthConfig } from "convex/server";
export default {
providers: [{ domain: process.env.CLERK_JWT_ISSUER_DOMAIN!, applicationID: "convex" }]
} satisfies AuthConfig;Frontend:
import { ConvexProviderWithClerk } from "convex/react-clerk";
<ConvexProviderWithClerk client={convex} useAuth={useAuth}>
<App />
</ConvexProviderWithClerk>In functions:
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Unauthorized");File Storage
// Upload
const storageId = await ctx.storage.store(fileBlob);
await db.insert("files", { storageId, name });
// Download
const url = await ctx.storage.getUrl(storageId);
// Delete
await ctx.storage.delete(storageId);HTTP Endpoints
File: convex/http.ts
import { httpRouter } from "convex/server";
import { httpAction } from "./_generated/server";
const http = httpRouter();
http.route({
path: "/webhooks/stripe",
method: "POST",
handler: httpAction(async (ctx, request) => {
const payload = await request.json();
await ctx.runMutation(internal.payments.process, { payload });
return new Response(null, { status: 200 });
}),
});
export default http;URL: https://<deployment>.convex.site
Scheduled Tasks
Cron Jobs (File: `convex/crons.ts`):
import { cronJobs } from "convex/server";
const crons = cronJobs();
crons.interval("cleanup", { hours: 1 }, internal.tasks.cleanup);
crons.daily("digest", { hourUTC: 9 }, internal.notifications.send);
export default crons;Scheduler API (in mutations/actions):
// Schedule for later
await ctx.scheduler.runAfter(delayMs, internal.tasks.process, { id });
// Schedule at specific time
await ctx.scheduler.runAt(timestamp, internal.tasks.remind, { userId });Best Practices
✅ DO
- Use indexes for queried fields
- Filter in application code, not with
.filter()on queries - Validate auth in protected functions
- Use pagination for large datasets
- Use
ConvexErrorfor structured errors clients can handle - Direct function calls, not multiple sequential
ctx.runQuery
❌ DON'T
- Use
.filter()on queries (use indexes or filter in code) - Make multiple sequential
runQuery/runMutationcalls (consolidate) - Forget to validate authentication
- Fetch entire tables without pagination
Error Handling
import { ConvexError } from "convex/values";
// Throw structured errors
if (!identity) {
throw new ConvexError({ code: "UNAUTHORIZED", message: "Login required" });
}
// Client catches and handles
if (error instanceof ConvexError) {
console.error(error.data.code, error.data.message);
}Deployment
Production:
npx convex deployWith frontend build:
npx convex deploy --cmd 'npm run build'Environment: Set CONVEX_DEPLOY_KEY for production
Testing
Install: npm install convex-test vitest --save-dev
import { convexTest } from "convex-test";
const t = convexTest(schema);
// Test mutations
await t.mutation(api.posts.create, { title: "Test" });
// Test queries
const posts = await t.query(api.posts.list);
expect(posts).toHaveLength(1);Common Issues
| Problem | Solution |
|---|---|
| Function not found | Run npx convex dev to regenerate types |
| Real-time not working | Check ConvexProvider wraps app |
| Type errors | Restart TypeScript server, regenerate types |
| Deploy fails | Verify CONVEX_DEPLOY_KEY is set |
Progressive Disclosure
Advanced topics in references:
- Complete setup: QUICK_START.md
- Deep dive on functions: CORE_CONCEPTS.md
- Auth patterns: AUTH.md
- Framework integration: FRONTEND.md
- Performance patterns: BEST_PRACTICES.md
- Production deployment: DEPLOYMENT.md
- Troubleshooting: TROUBLESHOOTING.md
- HTTP endpoints, cron, vector search, Convex Auth: ADVANCED.md
Your Approach
1. Assess first: New project or existing? Framework? Goals? 2. Start simple: Quick start → add complexity progressively 3. Embrace reactivity: Leverage automatic real-time updates 4. Type safety: Use TypeScript validation throughout 5. Index for performance: Always use indexes on queried fields 6. Validate auth: Check authentication in protected functions 7. Test before deploying: Use convex-test for unit tests
Convex Advanced Features
Advanced patterns for HTTP endpoints, scheduled tasks, vector search, full-text search, and error handling.
HTTP Endpoints
Create custom HTTP endpoints for webhooks, REST APIs, and external integrations.
File: convex/http.ts
import { httpRouter } from "convex/server";
import { httpAction } from "./_generated/server";
import { api, internal } from "./_generated/api";
const http = httpRouter();
// Basic endpoint
http.route({
path: "/",
method: "GET",
handler: httpAction(async (ctx, request) => {
return new Response("Hello from Convex!");
}),
});
// Webhook with signature verification
http.route({
path: "/webhooks/stripe",
method: "POST",
handler: httpAction(async (ctx, request) => {
const signature = request.headers.get("stripe-signature");
if (!signature) {
return new Response("Missing signature", { status: 401 });
}
const payload = await request.json();
await ctx.runMutation(internal.payments.processWebhook, {
payload,
signature,
});
return new Response(null, { status: 200 });
}),
});
// Dynamic path prefix
http.route({
pathPrefix: "/api/users/",
method: "GET",
handler: httpAction(async (ctx, request) => {
const url = new URL(request.url);
const userId = url.pathname.replace("/api/users/", "");
const user = await ctx.runQuery(api.users.get, { userId });
return new Response(JSON.stringify(user), {
headers: { "Content-Type": "application/json" },
});
}),
});
// File upload with CORS
http.route({
path: "/upload",
method: "POST",
handler: httpAction(async (ctx, request) => {
const blob = await request.blob();
const storageId = await ctx.storage.store(blob);
return new Response(JSON.stringify({ storageId }), {
status: 200,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": process.env.CLIENT_ORIGIN!,
},
});
}),
});
// CORS preflight
http.route({
path: "/upload",
method: "OPTIONS",
handler: httpAction(async (_, request) => {
return new Response(null, {
headers: {
"Access-Control-Allow-Origin": process.env.CLIENT_ORIGIN!,
"Access-Control-Allow-Methods": "POST",
"Access-Control-Allow-Headers": "Content-Type",
},
});
}),
});
export default http;Endpoint URL: https://<deployment>.convex.site (not .cloud)
Key Points:
- Request/response limit: 20MB
- Handle CORS for browser requests
- Use
ctx.runQuery,ctx.runMutationto access database - Access storage via
ctx.storage - Auth via
ctx.auth.getUserIdentity()with Bearer token
Scheduled Tasks & Cron Jobs
Cron Jobs
File: convex/crons.ts
import { cronJobs } from "convex/server";
import { internal } from "./_generated/api";
const crons = cronJobs();
// Run every minute
crons.interval(
"cleanup sessions",
{ minutes: 1 },
internal.sessions.cleanup,
);
// Run every hour
crons.interval(
"sync data",
{ hours: 1 },
internal.sync.fetchData,
);
// Run daily at 9 AM UTC
crons.daily(
"daily digest",
{ hourUTC: 9, minuteUTC: 0 },
internal.notifications.sendDigest,
);
// Run weekly on Mondays
crons.weekly(
"weekly report",
{ dayOfWeek: "monday", hourUTC: 6, minuteUTC: 0 },
internal.reports.generate,
);
// Run monthly on the 1st
crons.monthly(
"monthly billing",
{ day: 1, hourUTC: 16, minuteUTC: 0 },
internal.billing.process,
);
// Standard cron syntax (UTC)
crons.cron(
"every 15 minutes",
"*/15 * * * *",
internal.tasks.processQueue,
);
// With arguments
crons.daily(
"backup",
{ hourUTC: 2, minuteUTC: 0 },
internal.backups.create,
{ type: "full" },
);
export default crons;Scheduler API
Schedule functions from within mutations/actions:
import { mutation, internalMutation } from "./_generated/server";
import { internal } from "./_generated/api";
import { v } from "convex/values";
// Schedule for later
export const sendExpiringMessage = mutation({
args: { body: v.string(), expiresInMs: v.number() },
handler: async (ctx, args) => {
const id = await ctx.db.insert("messages", {
body: args.body,
createdAt: Date.now(),
});
// Schedule deletion
await ctx.scheduler.runAfter(
args.expiresInMs,
internal.messages.delete,
{ messageId: id },
);
return id;
},
});
// Schedule at specific time
export const scheduleReminder = mutation({
args: { reminderTime: v.number(), message: v.string() },
handler: async (ctx, args) => {
await ctx.scheduler.runAt(
args.reminderTime,
internal.notifications.send,
{ message: args.message },
);
},
});
// Cancel scheduled function
export const cancelScheduled = mutation({
args: { scheduledId: v.id("_scheduled_functions") },
handler: async (ctx, args) => {
await ctx.scheduler.cancel(args.scheduledId);
},
});Error Handling with ConvexError
Use ConvexError for structured, client-accessible errors.
import { mutation } from "./_generated/server";
import { v, ConvexError } from "convex/values";
export const createUser = mutation({
args: { email: v.string(), name: v.string() },
handler: async (ctx, args) => {
const existing = await ctx.db
.query("users")
.withIndex("by_email", (q) => q.eq("email", args.email))
.first();
if (existing) {
// Simple string error
throw new ConvexError("Email already in use");
}
// Structured error
if (!args.email.includes("@")) {
throw new ConvexError({
code: "INVALID_EMAIL",
message: "Email must contain @",
field: "email",
});
}
return await ctx.db.insert("users", args);
},
});
// Authorization error pattern
export const deletePost = mutation({
args: { postId: v.id("posts") },
handler: async (ctx, args) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) {
throw new ConvexError({
code: "UNAUTHENTICATED",
message: "Must be logged in",
});
}
const post = await ctx.db.get(args.postId);
if (!post) {
throw new ConvexError({
code: "NOT_FOUND",
message: "Post not found",
});
}
if (post.authorId !== identity.subject) {
throw new ConvexError({
code: "FORBIDDEN",
message: "Can only delete your own posts",
});
}
await ctx.db.delete(args.postId);
},
});Client-side handling:
import { ConvexError } from "convex/values";
try {
await createUser({ email, name });
} catch (error) {
if (error instanceof ConvexError) {
const data = error.data;
if (typeof data === "string") {
alert(data);
} else {
console.error(data.code, data.message);
}
}
}Vector Search
For semantic/similarity search with embeddings.
Schema
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export default defineSchema({
documents: defineTable({
title: v.string(),
content: v.string(),
category: v.string(),
embedding: v.array(v.float64()),
}).vectorIndex("by_embedding", {
vectorField: "embedding",
dimensions: 1536, // OpenAI ada-002
filterFields: ["category"],
}),
});Search Action
import { action, internalQuery } from "./_generated/server";
import { internal } from "./_generated/api";
import { v } from "convex/values";
export const search = action({
args: {
query: v.string(),
category: v.optional(v.string()),
},
handler: async (ctx, args) => {
// Generate embedding
const embedding = await generateEmbedding(args.query);
// Vector search
const results = await ctx.vectorSearch("documents", "by_embedding", {
vector: embedding,
limit: 10,
filter: args.category
? (q) => q.eq("category", args.category!)
: undefined,
});
// Fetch full documents
const docs = await ctx.runQuery(internal.search.fetchDocs, {
ids: results.map((r) => r._id),
});
return results.map((r, i) => ({
...docs[i],
score: r._score,
}));
},
});
// Helper query
export const fetchDocs = internalQuery({
args: { ids: v.array(v.id("documents")) },
handler: async (ctx, args) => {
return Promise.all(args.ids.map((id) => ctx.db.get(id)));
},
});Key Points:
vectorSearchonly available in actions- Results include
_score(-1 to 1, cosine similarity) - Max 256 results
- Put filters in
vectorSearchfor performance
Full-Text Search
For text-based search with relevance ranking.
Schema
messages: defineTable({
body: v.string(),
channel: v.string(),
author: v.string(),
}).searchIndex("search_body", {
searchField: "body",
filterFields: ["channel", "author"],
}),Search Query
export const searchMessages = query({
args: {
query: v.string(),
channel: v.optional(v.string()),
},
handler: async (ctx, args) => {
return await ctx.db
.query("messages")
.withSearchIndex("search_body", (q) => {
let search = q.search("body", args.query);
if (args.channel) {
search = search.eq("channel", args.channel);
}
return search;
})
.take(25);
},
});Key Points:
- Available in queries and mutations (unlike vector search)
- Results ranked by BM25 relevance
- Prefix matching on final term (good for typeahead)
- Reactive - updates in real-time
Convex Auth (Built-in)
Native authentication with OAuth and password support.
Installation
npm install @convex-dev/auth @auth/core@0.37.0
npx @convex-dev/authServer Setup
File: convex/auth.ts
import GitHub from "@auth/core/providers/github";
import Google from "@auth/core/providers/google";
import { Password } from "@convex-dev/auth/providers/Password";
import { convexAuth } from "@convex-dev/auth/server";
export const { auth, signIn, signOut, store } = convexAuth({
providers: [GitHub, Google, Password],
});File: convex/schema.ts
import { defineSchema } from "convex/server";
import { authTables } from "@convex-dev/auth/server";
export default defineSchema({
...authTables,
// Your tables
});Client Setup
import { ConvexAuthProvider } from "@convex-dev/auth/react";
<ConvexAuthProvider client={convex}>
<App />
</ConvexAuthProvider>Usage
import { useAuthActions } from "@convex-dev/auth/react";
import { getAuthUserId } from "@convex-dev/auth/server";
// Client: sign in
const { signIn, signOut } = useAuthActions();
signIn("github");
signIn("password", formData);
// Server: get user
const userId = await getAuthUserId(ctx);Environment Variables
CLI Commands
# List variables
npx convex env list
# Set variable
npx convex env set API_KEY "your-secret"
# Set for production
npx convex env set API_KEY "prod-key" --prod
# Remove variable
npx convex env unset API_KEYUsage in Functions
export const callApi = action({
handler: async (ctx) => {
const apiKey = process.env.API_KEY;
// Use apiKey
},
});
// System variables (always available)
process.env.CONVEX_CLOUD_URL // https://xxx.convex.cloud
process.env.CONVEX_SITE_URL // https://xxx.convex.siteLimits: Max 100 variables; names ≤40 chars; values ≤8KB
Convex Authentication & Authorization
Complete guide to implementing authentication with various providers.
Supported Providers
- Clerk (Recommended) - Easiest setup, modern UX
- Auth0 - Enterprise features
- Firebase - Google ecosystem
- Convex Auth - Built-in solution
Clerk Integration
Installation
npm install @clerk/clerk-reactBackend Configuration
File: convex/auth.config.ts
import { AuthConfig } from "convex/server";
export default {
providers: [
{
domain: process.env.CLERK_JWT_ISSUER_DOMAIN!,
applicationID: "convex"
}
]
} satisfies AuthConfig;Frontend Setup
import { ConvexProviderWithClerk } from "convex/react-clerk";
import { ClerkProvider, useAuth } from "@clerk/clerk-react";
import { ConvexReactClient } from "convex/react";
import { ReactNode } from "react";
const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!);
export function ConvexClientProvider({ children }: { children: ReactNode }) {
return (
<ClerkProvider publishableKey={process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY!}>
<ConvexProviderWithClerk client={convex} useAuth={useAuth}>
{children}
</ConvexProviderWithClerk>
</ClerkProvider>
);
}Accessing User Identity
import { mutation } from "./_generated/server";
export const createPost = mutation({
args: { title: v.string(), content: v.string() },
handler: async (ctx, args) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) {
throw new Error("Unauthorized");
}
// Access user info
const { subject, email, name, pictureUrl } = identity;
const postId = await ctx.db.insert("posts", {
...args,
authorId: subject, // Unique user ID from Clerk
authorName: name ?? email,
authorAvatar: pictureUrl,
createdAt: Date.now()
});
return postId;
}
});UserIdentity Interface
interface UserIdentity {
subject: string; // Unique ID (e.g., "user_abc123")
issuer: string; // Provider URL
email?: string; // User email
emailVerified?: boolean; // Email verification status
name?: string; // Display name
pictureUrl?: string; // Profile picture URL
}Auth0 Integration
Installation
npm install @auth0/auth0-reactBackend Configuration
File: convex/auth.config.ts
import { AuthConfig } from "convex/server";
export default {
providers: [
{
domain: process.env.AUTH0_DOMAIN!,
applicationID: "convex"
}
]
} satisfies AuthConfig;Frontend Setup
import { ConvexProviderWithAuth0 } from "convex/react-auth0";
import { Auth0Provider } from "@auth0/auth0-react";
<Auth0Provider
domain={process.env.NEXT_PUBLIC_AUTH0_DOMAIN!}
clientId={process.env.NEXT_PUBLIC_AUTH0_CLIENT_ID!}
authorizationParams={{ redirect_uri: window.location.origin }}
>
<ConvexProviderWithAuth0 client={convex}>
<App />
</ConvexProviderWithAuth0>
</Auth0Provider>Firebase Integration
Installation
npm install firebaseBackend Configuration
File: convex/auth.config.ts
import { AuthConfig } from "convex/server";
export default {
providers: [
{
domain: "https://securetoken.google.com/" + process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID!,
applicationID: "convex"
}
]
} satisfies AuthConfig;Frontend Setup
import { ConvexProviderWithFirebase } from "convex/react-firebase";
import { getAuth } from "firebase/auth";
const auth = getAuth();
<ConvexProviderWithFirebase client={convex} firebaseAuth={auth}>
<App />
</ConvexProviderWithFirebase>Authorization Patterns
Require Authentication
export const protectedOperation = mutation({
args: { data: v.string() },
handler: async (ctx, { data }) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) {
throw new Error("Must be authenticated");
}
// Proceed with operation
await ctx.db.insert("data", { data, userId: identity.subject });
}
});Resource Ownership
export const updatePost = mutation({
args: {
postId: v.id("posts"),
title: v.optional(v.string()),
content: v.optional(v.string())
},
handler: async (ctx, { postId, ...updates }) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) {
throw new Error("Unauthorized");
}
const post = await ctx.db.get(postId);
if (!post) {
throw new Error("Post not found");
}
// Check ownership
if (post.authorId !== identity.subject) {
throw new Error("Forbidden");
}
await ctx.db.patch(postId, updates);
}
});Role-Based Access
// Schema
users: defineTable({
name: v.string(),
role: v.union(
v.literal("user"),
v.literal("admin"),
v.literal("moderator")
)
})
// Function with role check
export const adminOperation = mutation({
handler: async (ctx) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) {
throw new Error("Unauthorized");
}
// Get user with role
const user = await ctx.db
.query("users")
.withIndex("by_subject", (q) => q.eq("subject", identity.subject))
.unique();
if (!user || user.role !== "admin") {
throw new Error("Forbidden: Admin only");
}
// Proceed with admin operation
}
});Team-Based Access
// Schema
teams: defineTable({
name: v.string()
}),
members: defineTable({
teamId: v.id("teams"),
userId: v.string(),
role: v.union(v.literal("owner"), v.literal("member"), v.literal("viewer"))
}).index("by_team", ["teamId"]).index("by_user", ["userId"])
// Check team membership
export const updateTeamResource = mutation({
args: {
teamId: v.id("teams"),
resourceId: v.id("resources"),
updates: v.any()
},
handler: async (ctx, { teamId, resourceId, updates }) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) {
throw new Error("Unauthorized");
}
// Check if user is team member
const membership = await ctx.db
.query("members")
.withIndex("by_team_user", (q) =>
q.eq("teamId", teamId).eq("userId", identity.subject)
)
.unique();
if (!membership) {
throw new Error("Not a team member");
}
// Check permissions
if (membership.role === "viewer") {
throw new Error("Insufficient permissions");
}
// Verify resource belongs to team
const resource = await ctx.db.get(resourceId);
if (!resource || resource.teamId !== teamId) {
throw new Error("Resource not found");
}
await ctx.db.patch(resourceId, updates);
}
});Testing Authenticated Functions
import { convexTest } from "convex-test";
it("requires authentication", async () => {
const t = convexTest(schema);
await expect(
t.mutation(api.posts.create, { title: "Test" })
).rejects.toThrow("Unauthorized");
});
it("allows authenticated users", async () => {
const t = convexTest(schema);
// Set up auth identity
const identity = {
subject: "user123",
issuer: "https://clerk.example.com",
email: "test@example.com",
name: "Test User"
};
t.withIdentity(identity);
const postId = await t.mutation(api.posts.create, { title: "Test" });
expect(postId).toBeDefined();
});Storing User Profiles
// Schema
users: defineTable({
subject: v.string(), // Unique ID from auth provider
name: v.string(),
email: v.string(),
avatarUrl: v.optional(v.string())
}).index("by_subject", ["subject"])
// Sync on auth
export const storeUser = mutation({
handler: async (ctx) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) {
throw new Error("Unauthorized");
}
const existingUser = await ctx.db
.query("users")
.withIndex("by_subject", (q) => q.eq("subject", identity.subject))
.unique();
if (existingUser) {
return existingUser._id;
}
const userId = await ctx.db.insert("users", {
subject: identity.subject,
name: identity.name ?? "Anonymous",
email: identity.email ?? "",
avatarUrl: identity.pictureUrl
});
return userId;
}
});Frontend Auth State
import { useConvexAuth } from "convex/react";
function UserProfile() {
const { isAuthenticated, isLoading } = useConvexAuth();
if (isLoading) {
return <div>Loading...</div>;
}
if (!isAuthenticated) {
return <PleaseLogin />;
}
return <Dashboard />;
}Convex Best Practices
Production-ready patterns and anti-patterns for Convex development.
Query Performance
✅ Use Indexes
// Schema
.defineTable({
channel: v.id("channels"),
author: v.id("users"),
timestamp: v.number()
})
.index("by_channel", ["channel"])
.index("by_channel_timestamp", ["channel", "timestamp"])
// Query
const messages = await db
.query("messages")
.withIndex("by_channel", (q) => q.eq("channel", channelId))
.collect();❌ Don't Use .filter() on Queries
// BAD - Full table scan
const tomsMessages = db
.query("messages")
.filter((q) => q.eq(q.field("author"), "Tom"))
.collect();
// GOOD - Filter in code
const allMessages = await db.query("messages").collect();
const tomsMessages = allMessages.filter((m) => m.author === "Tom");✅ Use Pagination
export const listMessages = query({
handler: async ({ db }) => {
return await db
.query("messages")
.paginate({ numItems: 50 });
}
});✅ Design Indexes for Query Patterns
// Schema - support multiple query patterns
.defineTable({})
.index("by_channel", ["channel"]) // channel=X
.index("by_channel_timestamp", ["channel", "timestamp"]) // channel=X, timestamp>Y
.index("by_author", ["author"]) // author=XFunction Organization
✅ Keep Functions Focused
// Good - Single responsibility
export const getPost = query({ /* ... */ });
export const listPosts = query({ /* ... */ });
export const createPost = mutation({ /* ... */ });
export const updatePost = mutation({ /* ... */ });
export const deletePost = mutation({ /* ... */ });✅ Use Clear Naming
// Good
export const getPostById = query(...)
export const listPostsByAuthor = query(...)
export const searchPosts = query(...)
// Avoid
export const get = query(...) // What?
export const list = query(...) // List what?❌ Don't Over-Use ctx.runQuery/runMutation
// BAD - Multiple round trips
export const process = action({
handler: async (ctx, { orderId }) => {
const order = await ctx.runQuery(api.orders.get, { orderId });
const payment = await ctx.runQuery(api.payments.get, { orderId });
const customer = await ctx.runQuery(api.customers.get, { customerId });
// ...
}
});
// GOOD - Single consolidated query
export const getOrderDetails = query({
args: { orderId: v.id("orders") },
handler: async ({ db }, { orderId }) => {
const order = await db.get(orderId);
const payment = await db.query("payments")
.withIndex("by_order", (q) => q.eq("orderId", orderId))
.unique();
const customer = await db.get(order.customerId);
return { order, payment, customer };
}
});
export const process = action({
handler: async (ctx, { orderId }) => {
const { order, payment, customer } = await ctx.runQuery(
api.orders.getOrderDetails,
{ orderId }
);
// ...
}
});Schema Design
✅ Plan for Growth
// Good - Uses indexes for queries
export default defineSchema({
messages: defineTable({
body: v.string(),
channel: v.id("channels"),
author: v.id("users"),
timestamp: v.number()
})
.index("by_channel", ["channel"])
.index("by_channel_timestamp", ["channel", "timestamp"])
.index("by_author", ["author"])
});✅ Use Relationships Wisely
// One-to-many - Use index
posts: defineTable({
authorId: v.id("users")
}).index("by_authorId", ["authorId"])
// Many-to-many - Use join table
postTags: defineTable({
postId: v.id("posts"),
tagId: v.id("tags")
}).index("by_postId", ["postId"]).index("by_tagId", ["tagId"])
// Embedded - For small, stable data
users: defineTable({
name: v.string(),
preferences: v.object({
theme: v.union(v.literal("light"), v.literal("dark")),
notifications: v.boolean()
})
})❌ Don't Over-Normalize
// Bad - Unnecessary separation
users: defineTable({
name: v.string(),
email: v.string()
}),
userProfiles: defineTable({
userId: v.id("users"),
bio: v.string() // Just put this in users
})
// Good - Keep related data together
users: defineTable({
name: v.string(),
email: v.string(),
bio: v.optional(v.string())
})Security
✅ Always Validate Authentication
export const createPost = mutation({
handler: async (ctx, args) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) {
throw new Error("Unauthorized");
}
// Proceed
}
});✅ Validate Authorization
export const updatePost = mutation({
args: { postId: v.id("posts"), ...updates },
handler: async (ctx, { postId, ...updates }) => {
const identity = await ctx.auth.getUserIdentity();
const post = await ctx.db.get(postId);
if (!post || post.authorId !== identity?.subject) {
throw new Error("Forbidden");
}
await ctx.db.patch(postId, updates);
}
});✅ Validate Input Types
export const createPost = mutation({
args: {
title: v.string(),
content: v.string(),
published: v.optional(v.boolean())
},
handler: async (ctx, args) => {
// Types are validated automatically
await ctx.db.insert("posts", args);
}
});❌ Don't Trust Client Input
// Bad - No validation
export const createPost = mutation({
args: {
// No args validation!
},
handler: async (ctx, args) => {
// args could be anything
await ctx.db.insert("posts", args);
}
});
// Good - Proper validation
export const createPost = mutation({
args: {
title: v.string(),
content: v.string()
},
handler: async (ctx, { title, content }) => {
await ctx.db.insert("posts", { title, content });
}
});Real-time Performance
✅ Minimize Query Results
// Good - Paginate
export const listMessages = query({
handler: async ({ db }) => {
return await db.query("messages").paginate({ numItems: 50 });
}
});
// Good - Filter with index
export const listRecent = query({
handler: async ({ db }) => {
return await db
.query("messages")
.withIndex("by_timestamp", (q) =>
q.gt("timestamp", Date.now() - 86400000)
)
.collect();
}
});✅ Use Preloading in Next.js
// Server component
export default async function Page() {
const preloaded = await preloadQuery(api.posts.list);
return <ClientPage preloaded={preloaded} />;
}Error Handling
✅ Use ConvexError
import { ConvexError } from "convex/values";
export const createPost = mutation({
args: { title: v.string() },
handler: async (ctx, { title }) => {
if (title.length === 0) {
throw new ConvexError({
code: "INVALID_TITLE",
message: "Title cannot be empty"
});
}
await ctx.db.insert("posts", { title });
}
});✅ Handle Errors Gracefully
const createPost = useMutation(api.posts.create, {
onError: (error) => {
if (error.data?.code === "INVALID_TITLE") {
alert("Please enter a title");
} else {
alert("Something went wrong");
}
}
});Testing
✅ Test Edge Cases
it("handles empty results", async () => {
const t = convexTest(schema);
const posts = await t.query(api.posts.list);
expect(posts).toEqual([]);
});
it("validates auth", async () => {
const t = convexTest(schema);
await expect(
t.mutation(api.posts.create, { title: "Test" })
).rejects.toThrow("Unauthorized");
});
it("enforces ownership", async () => {
const t = convexTest(schema);
const user1 = await t.runMutation(api.users.create, { name: "User1" });
const user2 = await t.runMutation(api.users.create, { name: "User2" });
const postId = await t.mutation(api.posts.create, {
title: "User1's post",
authorId: user1
});
await expect(
t.mutation(api.posts.update, {
postId,
authorId: user2 // Wrong user
})
).rejects.toThrow("Forbidden");
});Deployment
✅ Use Environment Variables
// convex/config.ts
export default makeConfig({
env: {
openaiApiKey: {
validation: v.string(),
access: "secret" // Only accessible in server functions
},
apiUrl: {
validation: v.string(),
access: "public" // Accessible in frontend
}
}
});✅ Use Deploy Keys for Production
# Production
CONVEX_DEPLOY_KEY=prod_key npx convex deploy
# With frontend
CONVEX_DEPLOY_KEY=prod_key npx convex deploy --cmd 'npm run build'Code Organization
Recommended Structure
convex/
├── schema.ts
├── config.ts
├── auth.config.ts
├── users.ts
├── posts.ts
├── comments.ts
├── notifications.ts
├── cron.ts # Scheduled jobs
├── http.ts # HTTP endpoints
└── lib/
├── auth.ts # Auth utilities
├── validation.ts # Validation helpers
└── types.ts # Shared typesGroup Related Functions
// posts.ts
export const get = query(...);
export const list = query(...);
export const listByAuthor = query(...);
export const create = mutation(...);
export const update = mutation(...);
export const delete = mutation(...);Performance Checklist
- [ ] All queries use indexes
- [ ] Large result sets use pagination
- [ ] Actions minimize ctx.runQuery/runMutation calls
- [ ] Auth checks are efficient (cached where possible)
- [ ] No .filter() on queries
- [ ] Schema has indexes for all query patterns
- [ ] File uploads use storage efficiently
- [ ] Environment variables properly configured
Convex Core Concepts
Deep dive into queries, mutations, actions, database operations, and schema design.
Server Functions
Convex provides three types of server functions with specific purposes and constraints.
Queries
Read-only functions that automatically update the UI when data changes.
Characteristics:
- Receive
{ db }- read-only database access - Cannot write to database
- Cannot make network requests
- Run transactionally
- Auto-subscribe - clients re-run when data changes
- Return data directly to frontend
Examples:
// Simple query - all documents
export const listAll = query({
handler: async ({ db }) => {
return await db.query("posts").collect();
}
});
// Query with arguments
export const getByAuthor = query({
args: { authorId: v.id("users") },
handler: async ({ db }, { authorId }) => {
return await db
.query("posts")
.withIndex("by_author", (q) => q.eq("authorId", authorId))
.collect();
}
});
// Query with authentication check
export const getMyPosts = query({
handler: async ({ db, auth }) => {
const identity = await auth.getUserIdentity();
if (!identity) return [];
return await db
.query("posts")
.withIndex("by_author", (q) => q.eq("authorId", identity.subject))
.collect();
}
});Mutations
Write operations that modify the database atomically.
Characteristics:
- Receive
{ db, auth }- read/write database access - Can insert, update, delete documents
- Run as transactions (all-or-nothing)
- Cannot make network requests
- Trigger query re-runs on connected clients
- Return values to frontend
Examples:
// Simple insert
export const create = mutation({
args: { title: v.string(), content: v.string() },
handler: async ({ db }, { title, content }) => {
const postId = await db.insert("posts", {
title,
content,
createdAt: Date.now()
});
return postId;
}
});
// Update with authorization check
export const update = mutation({
args: {
postId: v.id("posts"),
title: v.optional(v.string()),
content: v.optional(v.string())
},
handler: async ({ db, auth }, { postId, ...updates }) => {
const identity = await auth.getUserIdentity();
const post = await db.get(postId);
if (!post || post.authorId !== identity?.subject) {
throw new Error("Unauthorized");
}
await db.patch(postId, updates);
}
});
// Delete
export const remove = mutation({
args: { postId: v.id("posts") },
handler: async ({ db, auth }, { postId }) => {
const post = await db.get(postId);
const identity = await auth.getUserIdentity();
if (!post || post.authorId !== identity?.subject) {
throw new Error("Unauthorized");
}
await db.delete(postId);
}
});
// Transactional multi-document update
export const transferCredits = mutation({
args: {
from: v.id("users"),
to: v.id("users"),
amount: v.number()
},
handler: async ({ db }, { from, to, amount }) => {
const sender = await db.get(from);
const receiver = await db.get(to);
if (!sender || !receiver || sender.balance < amount) {
throw new Error("Invalid transfer");
}
// Both updates succeed or both fail
await db.patch(from, { balance: sender.balance - amount });
await db.patch(to, { balance: receiver.balance + amount });
}
});Actions
General-purpose serverless functions that can make external API calls.
Characteristics:
- Receive
{ db, auth, storage }- NO direct database access - Must use
ctx.runQuery()andctx.runMutation()for database - Can make network requests (fetch, APIs, LLMs)
- Can use
ctx.schedulerfor delayed execution - Return values to frontend
Examples:
// External API call
export const generateSummary = action({
args: { postId: v.id("posts") },
handler: async (ctx, { postId }) => {
// Get post via query
const post = await ctx.runQuery(api.posts.get, { postId });
// Call external API
const response = await fetch("https://api.openai.com/v1/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${process.env.OPENAI_API_KEY}`
},
body: JSON.stringify({
model: "gpt-4",
prompt: `Summarize: ${post.content}`
})
});
const { summary } = await response.json();
// Save via mutation
await ctx.runMutation(api.posts.update, {
postId,
summary
});
return summary;
}
});
// Scheduled task
export const scheduleReminder = action({
args: { taskId: v.id("tasks"), delayMs: v.number() },
handler: async (ctx, { taskId, delayMs }) => {
await ctx.scheduler.runAfter(
delayMs,
api.tasks.sendReminder,
{ taskId }
);
}
});
// Sending email
export const sendWelcomeEmail = action({
args: { email: v.string(), name: v.string() },
handler: async (ctx, { email, name }) => {
await fetch("https://api.resend.com/emails", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.RESEND_API_KEY}`,
"Content-Type": "application/json"
},
body: JSON.stringify({
from: "noreply@myapp.com",
to: email,
subject: `Welcome, ${name}!`,
html: `<p>Welcome to our app!</p>`
})
});
}
});Database Operations
CRUD Operations
// CREATE
const id = await db.insert("posts", {
title: "My Post",
content: "Hello world",
authorId: userId,
createdAt: Date.now()
});
// READ - Single document
const post = await db.get(id);
// READ - With index
const posts = await db
.query("posts")
.withIndex("by_author", (q) => q.eq("authorId", userId))
.collect();
// UPDATE
await db.patch(id, { title: "Updated Title" });
// DELETE
await db.delete(id);Query Building
// Basic query
const all = await db.query("posts").collect();
// With index - single field
const byAuthor = await db
.query("posts")
.withIndex("by_author", (q) => q.eq("authorId", userId))
.collect();
// With index - range query
const recent = await db
.query("posts")
.withIndex("by_created", (q) =>
q.gt("createdAt", Date.now() - 86400000)
)
.collect();
// With index - compound
const byAuthorAndStatus = await db
.query("posts")
.withIndex("by_author_status", (q) =>
q.eq("authorId", userId).eq("status", "published")
)
.collect();
// Order with index
const ordered = await db
.query("posts")
.withIndex("by_created", (q) => q.order("desc"))
.collect();
// Pagination
const page = await db
.query("posts")
.paginate({ numItems: 50 });
// First page
const firstPage = await page.next();
// Continue pagination
const secondPage = await page.next(); // Use continueCursor from first pageQuery Methods
| Method | Returns | Use Case |
|---|---|---|
.collect() | Array<T> | Get all results |
.first() | `T \ | null` |
.unique() | T | Get exactly one (throws if not unique) |
.paginate({ numItems }) | PaginationResult | Paginate large result sets |
Schema Design
Value Types Reference
// Primitives
v.string() // "hello"
v.number() // 42, 3.14
v.boolean() // true, false
v.null() // null
// ID references
v.id("users") // Foreign key to users table
// Collections
v.array(v.string()) // ["a", "b", "c"]
v.object({
name: v.string(),
age: v.number()
})
// Optional
v.optional(v.string()) // string | null | undefined
// Union
v.union(v.string(), v.number()) // string | number
// Literal
v.literal("active"), v.literal("inactive") // "active" | "inactive"Index Design
// Single field index
.defineTable({ ... })
.index("by_email", ["email"])
// Compound index
.defineTable({ ... })
.index("by_author_created", ["authorId", "createdAt"])
// Using indexes in queries
// Single field
db.query("posts")
.withIndex("by_author", (q) => q.eq("authorId", userId))
// Compound - must use prefix fields
db.query("posts")
.withIndex("by_author_created", (q) =>
q.eq("authorId", userId).gt("createdAt", timestamp)
)
// Order with index
db.query("posts")
.withIndex("by_created", (q) => q.order("desc"))Index Query Operators
q.eq("field", value) // Equals
q.neq("field", value) // Not equals
q.lt("field", value) // Less than
q.lte("field", value) // Less than or equal
q.gt("field", value) // Greater than
q.gte("field", value) // Greater than or equalRelationships
One-to-Many
// Schema
users: defineTable({
name: v.string(),
email: v.string()
}),
posts: defineTable({
title: v.string(),
authorId: v.id("users")
}).index("by_authorId", ["authorId"])
// Query posts by user
const userPosts = await db
.query("posts")
.withIndex("by_authorId", (q) => q.eq("authorId", userId))
.collect();Many-to-Many
// Schema
posts: defineTable({ title: v.string() }),
tags: defineTable({ name: v.string() }),
postTags: defineTable({
postId: v.id("posts"),
tagId: v.id("tags")
}).index("by_postId", ["postId"]).index("by_tagId", ["tagId"])
// Get tags for a post
const tagRelations = await db
.query("postTags")
.withIndex("by_postId", (q) => q.eq("postId", postId))
.collect();
const tagIds = tagRelations.map(r => r.tagId);Context Objects
Query Context
{
db: GenericDatabaseReader, // Read-only database
auth: AuthManager // Authentication
}Mutation Context
{
db: GenericDatabaseWriter, // Read/write database
auth: AuthManager // Authentication
storage: Storage // File storage
}Action Context
{
auth: AuthManager, // Authentication
scheduler: Scheduler, // Scheduled tasks
runQuery: QueryRunner, // Run queries
runMutation: MutationRunner // Run mutations
}Convex Deployment Guide
Complete guide for deploying Convex applications to production.
Production Deploy Keys
Generate Deploy Key
1. Go to Convex Dashboard 2. Select your project 3. Navigate to Settings → Production Deploy Keys 4. Click Generate Production Deploy Key 5. Copy the key (you won't see it again!)
Set Environment Variable
# Terminal / shell
export CONVEX_DEPLOY_KEY=your_deploy_key_here
# .env file
CONVEX_DEPLOY_KEY=convex_prod_abc123...Vercel Environment Variable
1. Go to Vercel project Settings → Environment Variables 2. Add CONVEX_DEPLOY_KEY with your deploy key 3. Select all environments (Production, Preview, Development)
Deployment Commands
Basic Deployment
# Deploy Convex functions only
npx convex deployWith Frontend Build
# Build and deploy together
npx convex deploy --cmd 'npm run build'
# Vercel-style
npx convex deploy --cmd 'next build'Dry Run
# Check what would be deployed
npx convex deploy --dry-runSpecific Environment
# Deploy to production
npx convex deploy --env production
# Deploy to preview
npx convex deploy --env previewEnvironment Configuration
Define Environment Variables
File: convex/config.ts
import { defineConfig, v } from "convex/config";
export default defineConfig({
env: {
// Public - accessible in frontend
apiUrl: {
validation: v.string(),
access: "public"
},
// Secret - only accessible in server functions
openaiApiKey: {
validation: v.string(),
access: "secret"
},
// Optional
optionalVar: {
validation: v.optional(v.string()),
access: "public"
}
}
});Use in Functions
// Actions/Mutations
export const callAPI = action({
handler: async (ctx) => {
const apiKey = process.env.OPENAI_API_KEY;
const response = await fetch("https://api.openai.com/v1/...", {
headers: {
"Authorization": `Bearer ${apiKey}`
}
});
return await response.json();
}
});Access in Frontend
// Access public env vars
const apiUrl = process.env.NEXT_PUBLIC_CONVEX_URL;Vercel Deployment
Configure Build Command
Vercel Dashboard: 1. Go to Settings → Build & Development 2. Set Build Command to:
npx convex deploy --cmd 'npm run build'vercel.json Configuration
{
"buildCommand": "npx convex deploy --cmd 'npm run build'",
"env": {
"CONVEX_DEPLOY_KEY": "@convex-deploy-key"
}
}Complete Vercel Setup
1. Set environment variables:
CONVEX_DEPLOY_KEY- Production deploy keyNEXT_PUBLIC_CONVEX_URL- Auto-set by deploy command
2. Configure build:
npx convex deploy --cmd 'npm run build'3. Deploy:
vercel --prodNetlify Deployment
netlify.toml Configuration
[build]
command = "npx convex deploy --cmd 'npm run build'"
[build.environment]
CONVEX_DEPLOY_KEY = "@convex_deploy_key"CI/CD Integration
GitHub Actions
File: .github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '18'
- name: Install dependencies
run: npm ci
- name: Deploy to Convex
env:
CONVEX_DEPLOY_KEY: ${{ secrets.CONVEX_DEPLOY_KEY }}
run: npx convex deploy --cmd 'npm run build'GitHub Actions for Preview
name: Deploy Preview
on:
pull_request:
jobs:
deploy-preview:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
- name: Install dependencies
run: npm ci
- name: Deploy preview
env:
CONVEX_DEPLOY_KEY: ${{ secrets.CONVEX_DEPLOY_KEY }}
run: npx convex deployMonitoring & Logging
Dashboard Monitoring
- Functions tab: See all function calls and performance
- Database tab: View and query your data
- Deployment logs: Track deployment history
Error Tracking
// In your functions
import { ConvexError } from "convex/values";
export const sensitiveOp = mutation({
handler: async (ctx) => {
throw new ConvexError({
code: "OPERATION_FAILED",
message: "Detailed error message",
context: { userId: "abc123" }
});
}
});Custom Logging
export const processPayment = mutation({
handler: async (ctx, { amount }) => {
console.log("Processing payment:", { amount, timestamp: Date.now() });
try {
// Process payment
} catch (error) {
console.error("Payment failed:", error);
throw error;
}
}
});Custom Domain
Set Up Custom Domain
1. Go to Convex Dashboard 2. Navigate to Settings → Domains 3. Click Add Custom Domain 4. Enter your domain (e.g., api.yourapp.com) 5. Configure DNS records
DNS Configuration
Type: CNAME
Name: api
Value: your-project.convex.cloudUpdate Frontend Configuration
const convex = new ConvexReactClient("https://api.yourapp.com");Production Checklist
Before deploying to production:
Security
- [ ] All environment variables are set
- [ ] Deploy keys are properly configured
- [ ] Authentication is enabled
- [ ] Authorization checks are in place
- [ ] Secret keys are not exposed
Performance
- [ ] Indexes are created for all queries
- [ ] Pagination is implemented for large datasets
- [ ] File uploads have size limits
- [ ] No
.filter()on queries
Monitoring
- [ ] Error tracking is configured
- [ ] Logging is implemented
- [ ] Dashboard monitoring is set up
- [ ] Alerts are configured
Testing
- [ ] All tests pass
- [ ] Integration tests pass
- [ ] Manual testing completed
- [ ] Edge cases covered
Preview Deployments
Automatic Preview Environments
Convex automatically creates preview environments for each branch.
# Create preview deployment
git checkout -b feature/new-feature
npx convex deploy
# This creates a preview environment
# URL: https://your-project-branch-name.convex.cloudClean Up Preview Environments
# Delete preview environment
npx convex deploy delete-env --env previewRollback
Rollback Deployment
# View deployment history
npx convex deploy history
# Rollback to previous deployment
npx convex deploy rollbackRollback Specific Version
# Rollback to specific deployment
npx convex deploy rollback --version 42Scaling
Convex automatically scales your application. No manual configuration needed.
- Database: Automatically scales read/write capacity
- Functions: Auto-scales based on load
- Storage: Unlimited file storage
- Rate limits: Configurable in dashboard
Rate Limiting
// Convex automatically enforces rate limits
// Configure in dashboard: Settings → Rate LimitsMigration from Development
Export Development Data
# Export data from development
npx convex export --developmentImport to Production
# Import to production
CONVEX_DEPLOY_KEY=prod_key npx convex import --production data.jsonSchema Migration
# Deploy schema changes
npx convex deploy
# Convex handles schema migrations automatically
# No downtime or manual migration neededConvex Frontend Integration
Comprehensive guide for integrating Convex with frontend frameworks.
React
Installation
npm install convexBasic Setup
import { ConvexProvider, ConvexReactClient } from "convex/react";
import { ReactNode } from "react";
const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!);
export function App({ children }: { children: ReactNode }) {
return (
<ConvexProvider client={convex}>
{children}
</ConvexProvider>
);
}Hooks Reference
import { useQuery, useMutation, useAction } from "convex/react";
import { api } from "../convex/_generated/api";
// Query - auto-subscribes to updates
const messages = useQuery(api.messages.list);
const message = useQuery(api.messages.get, { messageId });
// With undefined check
const messages = useQuery(api.messages.list, { channelId }, {
onError: (error) => console.error(error)
});
// Mutation
const sendMessage = useMutation(api.messages.send);
const handleSubmit = () => {
sendMessage({ body: "Hello!", channel: "general" });
};
// Action
const generateSummary = useAction(api.ai.summarize);Real-time Pagination
function PaginatedList() {
const results = useQuery(api.messages.listPaginated, {
numItems: 20
});
if (!results) return <div>Loading...</div>;
const { page, continueCursor, loadMore, status } = results;
return (
<div>
{page.map((msg) => <div key={msg._id}>{msg.body}</div>)}
{continueCursor && (
<button
onClick={loadMore}
disabled={status === "Exhausted" || status === "LoadingMore"}
>
Load more
</button>
)}
</div>
);
}Next.js
App Router
Install:
npm install convexProvider setup:
// app/providers.tsx
"use client";
import { ConvexProvider, ConvexReactClient } from "convex/react";
const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!);
export function ConvexClientProvider({
children,
}: {
children: React.ReactNode;
}) {
return <ConvexProvider client={convex}>{children}</ConvexProvider>;
}Root layout:
// app/layout.tsx
import { ConvexClientProvider } from "./providers";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<ConvexClientProvider>{children}</ConvexClientProvider>
</body>
</html>
);
}Server-side data preloading:
// app/posts/page.tsx
import { preloadQuery } from "convex/nextjs";
import { api } from "@/convex/_generated/api";
import { PostsClient } from "./PostsClient";
export default async function PostsPage() {
const preloaded = await preloadQuery(api.posts.list);
return <PostsClient preloadedPosts={preloaded} />;
}Client component:
// app/posts/PostsClient.tsx
"use client";
import { usePreloadedQuery } from "convex/react";
export function PostsClient({
preloadedPosts,
}: {
preloadedPosts: Preloaded<typeof api.posts.list>;
}) {
const posts = usePreloadedQuery(preloadedPosts);
return (
<div>
{posts.map((post) => (
<div key={post._id}>{post.title}</div>
))}
</div>
);
}Pages Router
Provider:
// pages/_app.tsx
import { ConvexProvider, ConvexReactClient } from "convex/react";
import type { AppProps } from "next/app";
const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!);
function MyApp({ Component, pageProps }: AppProps) {
return (
<ConvexProvider client={convex}>
<Component {...pageProps} />
</ConvexProvider>
);
}
export default MyApp;Server-side rendering:
// pages/index.tsx
import { preloadQuery } from "convex/nextjs";
import { api } from "../convex/_generated/api";
import { Home } from "./Home";
export async function getServerSideProps() {
const preloaded = await preloadQuery(api.posts.list);
return { props: { preloaded } };
}Vue
Installation
npm install convexSetup
// main.ts
import { ConvexClient } from "convex/browser";
import { createApp } from "vue";
const convex = new ConvexClient(import.meta.env.VITE_CONVEX_URL);
const app = createApp(App);
app.provide("convex", convex);
app.mount("#app");Usage
<script setup lang="ts">
import { useQuery, useMutation } from "convex/vue";
import { api } from "../convex/_generated/api";
const messages = useQuery(api.messages.list);
const sendMessage = useMutation(api.messages.send);
const handleSend = () => {
sendMessage({ body: "Hello!", channel: "general" });
};
</script>
<template>
<div>
<div v-for="msg in messages" :key="msg._id">
{{ msg.body }}
</div>
<button @click="handleSend">Send</button>
</div>
</template>Svelte
Installation
npm install convex-svelteSetup
<!-- app.svelte -->
<script>
import { setupConvex } from 'convex-svelte';
import { PUBLIC_CONVEX_URL } from '$env/static/public';
setupConvex(PUBLIC_CONVEX_URL);
</script>
<slot />Usage
<script lang="ts">
import { useQuery, useMutation } from 'convex-svelte';
import { api } from '../convex/_generated/api';
const messages = useQuery(api.messages.list);
const sendMessage = useMutation(api.messages.send);
</script>
{#if $messages}
{#each $messages as msg}
<div>{msg.body}</div>
{/each}
{/if}
<button on:click={() => sendMessage({ body: 'Hello!' })}>
Send
</button>React Native
Installation
npm install convex react-native-url-polyfillSetup
// App.tsx
import { ConvexProvider, ConvexReactClient } from "convex/react";
import "react-native-url-polyfill/auto";
const convex = new ConvexReactClient(CONVEX_URL);
export default function App() {
return (
<ConvexProvider client={convex}>
<YourApp />
</ConvexProvider>
);
}Environment Variables
Vite (React, Vue, Svelte)
# .env
VITE_CONVEX_URL=https://your-project.convex.cloudconst convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL);Next.js
# .env.local
NEXT_PUBLIC_CONVEX_URL=https://your-project.convex.cloudconst convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!);React Native
# .env
CONVEX_URL=https://your-project.convex.cloudAdvanced Patterns
Optimistic Updates
function TodoList() {
const todos = useQuery(api.todos.list) ?? [];
const addTodo = useMutation(api.todos.create);
const [pendingTodos, setPendingTodos] = useState<Set<string>>(new Set());
const handleAdd = async (title: string) => {
const tempId = `temp-${Date.now()}`;
// Optimistic update
setPendingTodos((prev) => new Set(prev).add(tempId));
try {
await addTodo({ title });
} catch {
// Rollback on error
setPendingTodos((prev) => {
const next = new Set(prev);
next.delete(tempId);
return next;
});
}
};
const allTodos = [
...todos,
...Array.from(pendingTodos).map((id) => ({
_id: id,
title: "Saving...",
_creationTime: Date.now(),
}))
];
return <ul>{allTodos.map((todo) => <li key={todo._id}>{todo.title}</li>)}</ul>;
}Custom Hooks
function useMessages(channelId: Id<"channels">) {
const messages = useQuery(api.messages.list, { channelId });
const sendMessage = useMutation(api.messages.send);
const send = useCallback(
(body: string) => {
sendMessage({ body, channel: channelId });
},
[sendMessage, channelId]
);
return { messages, send: sendMessage };
}Error Handling
function DataComponent() {
const posts = useQuery(api.posts.list, {}, {
onError: (error) => {
console.error("Query failed:", error);
// Handle error (show toast, etc.)
}
});
const createPost = useMutation(api.posts.create, {
onError: (error) => {
console.error("Mutation failed:", error);
alert("Failed to create post");
}
});
if (posts === undefined) {
return <div>Loading...</div>;
}
return <div>{/* render posts */}</div>;
}Convex Quick Start Guide
Complete step-by-step guide for setting up a Convex project from scratch.
Creating a New Project
Option 1: Using the CLI (Recommended)
npm create convex@latestThe CLI will prompt you to: 1. Select a frontend framework (React/Vite, Next.js, etc.) 2. Choose whether to add Convex Auth 3. Name your project 4. Select a directory
Option 2: Manual Setup
# Create project directory
mkdir my-convex-app && cd my-convex-app
# Initialize npm
npm init -y
# Install Convex
npm install convex
# Initialize Convex
npx convex devProject Structure
my-convex-app/
├── convex/
│ ├── schema.ts # Database schema
│ ├── config.ts # Environment variables
│ ├── auth.config.ts # Authentication config
│ └── _generated/ # Auto-generated types
├── src/ # Frontend code
├── package.json
└── convex.json # Project configurationYour First Convex App
Step 1: Define Schema
File: convex/schema.ts
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export default defineSchema({
messages: defineTable({
body: v.string(),
author: v.string(),
createdAt: v.number()
})
});Step 2: Write a Query
File: convex/messages.ts
import { query } from "./_generated/server";
export const list = query({
handler: async ({ db }) => {
return await db.query("messages").collect();
}
});Step 3: Write a Mutation
File: convex/messages.ts (add to existing file)
import { mutation } from "./_generated/server";
import { v } from "convex/values";
export const send = mutation({
args: {
body: v.string(),
author: v.string()
},
handler: async ({ db }, { body, author }) => {
await db.insert("messages", {
body,
author,
createdAt: Date.now()
});
}
});Step 4: Frontend Integration
React:
import { ConvexProvider, ConvexReactClient } from "convex/react";
const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL);
function App() {
return (
<ConvexProvider client={convex}>
<Messages />
</ConvexProvider>
);
}
function Messages() {
const messages = useQuery(api.messages.list);
const sendMessage = useMutation(api.messages.send);
return (
<div>
{messages?.map((msg) => (
<div key={msg._id}>{msg.author}: {msg.body}</div>
))}
<button onClick={() => sendMessage({ body: "Hello!", author: "Me" })}>
Send
</button>
</div>
);
}Step 5: Run Development
# Terminal 1: Start Convex backend
npx convex dev
# Terminal 2: Start frontend
npm run devStep 6: Verify
1. Check your browser - you should see the app 2. Check the dashboard at https://dashboard.convex.dev 3. Click "Send" button - messages should appear in real-time 4. Check the dashboard - you should see the data in your database
Environment Variables
Development:
- Automatically set by
npx convex dev - Check
.convex/local.envfor local URL
Production:
- Set
NEXT_PUBLIC_CONVEX_URLorVITE_CONVEX_URL - Set
CONVEX_DEPLOY_KEYfor deployments
Next Steps
After your first app works:
1. Add indexes to your schema for better performance 2. Add authentication with Clerk or other providers 3. Add file storage for uploads 4. Set up testing with convex-test 5. Deploy to production
Common Issues
Port already in use:
# Kill process on port 3210
lsof -ti:3210 | xargs kill -9Types not generated:
rm -rf convex/_generated
npx convex devFrontend can't connect: 1. Check CONVEX_URL environment variable 2. Verify ConvexProvider wraps your app 3. Check browser console for errors
Convex Troubleshooting Guide
Solutions to common issues and problems when working with Convex.
Development Environment Issues
Port Already in Use
Problem: Error: Port 3210 is already in use
Solutions:
1. Kill process on port 3210:
# Linux/Mac
lsof -ti:3210 | xargs kill -9
# Windows
netstat -ano | findstr :3210
taskkill /PID <PID> /F2. Use different port:
CONVEX_PORT=3211 npx convex devTypes Not Generated
Problem: TypeScript errors: Cannot find module '../convex/_generated/api'
Solutions:
1. Regenerate types:
rm -rf convex/_generated
npx convex dev2. Restart TypeScript server:
- VS Code: Command Palette → "TypeScript: Restart TS Server"
- Cursor: Command Palette → "Developer: Reload Window"
3. Verify convex/schema.ts exists and is valid
Convex Dev Fails to Start
Problem: npx convex dev fails
Solutions:
1. Check Node.js version (requires 18+):
node --version2. Clear Convex cache:
rm -rf .convex
npx convex dev3. Check network connection to Convex cloud
4. Verify convex/schema.ts is valid TypeScript
Function Issues
Function Not Found
Problem: TypeError: api.module.function is not a function
Solutions:
1. Run npx convex dev to regenerate types
2. Check function file exists in convex/ folder
3. Verify function is exported:
export const myFunction = query({ /* ... */ });
// NOT:
const myFunction = query({ /* ... */ });4. Check for TypeScript errors in function file
5. Restart TypeScript language server
Function Not Reactive
Problem: UI doesn't update when data changes
Solutions:
1. Verify using useQuery hook (not React Query):
// Correct
import { useQuery } from "convex/react";
// Wrong
import { useQuery } from "@tanstack/react-query";2. Check ConvexProvider wraps your app:
<ConvexProvider client={convex}>
<App />
</ConvexProvider>3. Check browser console for WebSocket errors
4. Verify mutation completed successfully
Query Returns undefined
Problem: Query returns undefined instead of data
Causes:
1. Query still loading (check with if (data === undefined)) 2. Query threw error (check browser console) 3. No data in database 4. Index doesn't exist for query
Solutions:
const data = useQuery(api.posts.list);
if (data === undefined) {
return <div>Loading...</div>;
}
if (data === null) {
return <div>Error loading data</div>;
}
return <div>{/* render data */}</div>;Authentication Issues
User Not Authenticated
Problem: auth.getUserIdentity() returns null
Solutions:
1. Verify auth provider setup:
// Clerk
<ConvexProviderWithClerk client={convex} useAuth={useAuth}>2. Check user is logged in on frontend
3. Verify auth.config.ts exists
4. Check environment variables are set:
- Clerk:
CLERK_JWT_ISSUER_DOMAIN - Auth0:
AUTH0_DOMAIN
JWT Validation Failed
Problem: Error: JWT validation failed
Solutions:
1. Verify auth.config.ts domain matches auth provider:
// Clerk - get from Clerk Dashboard
domain: process.env.CLERK_JWT_ISSUER_DOMAIN!
// Should be: https://<your-clerk-app>.clerk.accounts.dev2. Check environment variable is set correctly
3. Verify auth provider application ID is "convex"
Performance Issues
Slow Queries
Problem: Queries take too long
Solutions:
1. Add indexes:
.index("by_field", ["field"])2. Use pagination:
.paginate({ numItems: 50 })3. Avoid .filter() on queries
4. Check dashboard for query performance metrics
Too Many Re-renders
Problem: Component re-renders excessively
Solutions:
1. Move expensive computation outside component:
// Good
const formatted = useMemo(
() => messages.map(formatMessage),
[messages]
);2. Use useMemo and useCallback
3. Check if query arguments are stable:
// Bad - creates new object each render
useQuery(api.posts.list, { filter: { status: "active" } });
// Good - stable object
const filter = useMemo(() => ({ status: "active" }), []);
useQuery(api.posts.list, { filter });Database Issues
Document Not Found
Problem: db.get(id) returns null
Solutions:
1. Verify ID is correct type:
v.id("posts") // NOT just string2. Check document exists in dashboard
3. Verify table name matches schema
Insert Fails
Problem: db.insert() throws error
Common causes:
1. Invalid data type:
// Schema: v.string()
await db.insert("posts", { title: 123 }); // Error!
// Correct
await db.insert("posts", { title: "My Post" });2. Missing required fields
3. Invalid ID reference:
// Bad
authorId: "user123" // Just a string
// Good
authorId: v.id("users") // Valid ID typeFile Storage Issues
Upload Fails
Problem: File upload fails
Solutions:
1. Check file size limits (default 10MB)
2. Verify file is Blob/ArrayBuffer:
const bytes = new Uint8Array(await file.arrayBuffer());
await ctx.storage.store(bytes);3. Check storage quota in dashboard
File Download Fails
Problem: ctx.storage.getUrl() returns invalid URL
Solutions:
1. Verify storageId is valid
2. Check file exists in dashboard
3. Use correct URL generation:
const url = await ctx.storage.getUrl(storageId);Deployment Issues
Deploy Fails
Problem: npx convex deploy fails
Solutions:
1. Verify CONVEX_DEPLOY_KEY is set:
echo $CONVEX_DEPLOY_KEY2. Check deploy key is for correct project
3. Run npx convex deploy --dry-run to check
4. Verify all functions compile without errors
Environment Variables Not Set
Problem: process.env.VAR is undefined
Solutions:
1. Verify variable is in convex/config.ts:
env: {
myVar: {
validation: v.string(),
access: "public" // or "secret"
}
}2. Set environment variable locally:
# Development
# In .convex/local.dev.json
{
"myVar": "value"
}
# Production
# In dashboard or CI/CD3. Restart npx convex dev after adding
Frontend Integration Issues
React: Provider Missing
Problem: Error: ConvexProvider not found
Solution:
import { ConvexProvider, ConvexReactClient } from "convex/react";
const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!);
<ConvexProvider client={convex}>
<App />
</ConvexProvider>Next.js: Hydration Mismatch
Problem: React hydration errors
Solutions:
1. Use usePreloadedQuery for server components:
const preloaded = await preloadQuery(api.posts.list);
return <ClientPage preloaded={preloaded} />;2. Defer non-SSR queries:
const [clientMounted, setClientMounted] = useState(false);
useEffect(() => setClientMounted(true), []);
const data = useQuery(api.data.get, {}, {
enabled: clientMounted
});TypeScript Errors
Type Mismatch
Problem: Type errors in generated API
Solutions:
1. Regenerate types:
rm -rf convex/_generated
npx convex dev2. Check schema is valid
3. Verify function args match schema:
// Schema: v.id("posts")
// Function:
args: { postId: v.id("posts") }Missing Types
Problem: Cannot import types
Solutions:
1. Import from generated types:
import type { Id, DocumentByName } from "../convex/_generated/dataModel";
type Post = DocumentByName["posts"];
type PostId = Id<"posts">;2. Check convex/_generated/dataModel.ts exists
Debugging Tips
Enable Debug Logging
// In development
console.log("Query result:", data);
// In Convex functions
export const myQuery = query({
handler: async ({ db }) => {
console.log("Running query...");
const result = await db.query("posts").collect();
console.log("Found", result.length, "posts");
return result;
}
});Use Dashboard
- Functions tab: See all function calls and performance
- Database tab: Query and view data
- Logs tab: See console.log output from functions
- Deployment logs: Track deployment history
Network Tab
Check browser Network tab for:
- WebSocket connection (should be
wss://) - API requests to Convex
- Error responses
Getting Help
If you can't solve the issue:
1. Check Convex Docs 2. Search GitHub Issues 3. Ask in Discord 4. Contact Convex Support via dashboard
When asking for help, include:
- Error messages (full stack trace)
- Code snippets
- Environment (OS, Node version, framework)
- Steps to reproduce