Claude-skill-registry Convex Backend Development
Build Convex backends with queries, mutations, actions, HTTP endpoints, and schemas. Comprehensive guide for all Convex patterns and workflows.
git clone https://github.com/majiayu000/claude-skill-registry
T=$(mktemp -d) && git clone --depth=1 https://github.com/majiayu000/claude-skill-registry "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/data/convex-development" ~/.claude/skills/majiayu000-claude-skill-registry-convex-backend-development-f5004f && rm -rf "$T"
skills/data/convex-development/SKILL.mdSkill: Convex Backend Development
Complete guide for implementing Convex backend functionality including queries, mutations, actions, HTTP endpoints, file storage, and database schemas.
When to Use
- Implementing Convex backend functionality
- Creating database schemas with tables and indexes
- Adding HTTP endpoints for webhooks or external API access
- Implementing async actions and scheduled tasks
- Setting up authentication and authorization
- Debugging Convex-related issues
- Working with file storage and URLs
Domain Knowledge
Critical Patterns
Function Type Separation (CRITICAL)
Convex has three function types, each with specific purposes:
-
Queries: Read data only, cannot modify database
- Used for fetching data
- Can be called from components
- Cached and reactive
-
Mutations: Write to database
- Used for creating, updating, deleting data
- Can schedule actions
- Transactional
-
Actions: External side effects
- Call third-party APIs
- Send emails
- Interact with external services
- Cannot directly access database (must call queries/mutations)
Rule: Schedule actions from mutations, never call actions directly from mutations.
// ❌ Wrong - calling action directly export const myMutation = mutation({ handler: async (ctx) => { await ctx.runAction(api.actions.sendEmail); // Don't do this }, }); // ✅ Correct - schedule action export const myMutation = mutation({ handler: async (ctx, args) => { await ctx.scheduler.runAfter(0, api.actions.sendEmail, args); }, });
CORS Headers for HTTP Endpoints (CRITICAL)
All HTTP endpoints accessed from browsers MUST include CORS headers, or browsers will block requests.
Required CORS headers constant:
const CORS_HEADERS = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "POST, OPTIONS, GET, PUT, DELETE", "Access-Control-Allow-Headers": "Content-Type, Authorization", "Vary": "Origin", };
Must include in:
- All successful responses
- All error responses
- OPTIONS preflight responses
Why this matters: Without CORS headers, your HTTP endpoint will work in Postman/curl but fail in browser applications.
Storage URL Generation
Always use
ctx.storage.getUrl() for storage URLs, never construct URLs manually.
// ❌ Wrong - manual URL construction const url = `https://your-deployment.convex.cloud/storage/${storageId}`; // ✅ Correct - use ctx.storage.getUrl() const url = await ctx.storage.getUrl(storageId);
Why: Manual URLs don't work and return null. The storage system requires proper URL generation through the API.
HTTP Endpoint Domains (CRITICAL)
Use
.convex.site for HTTP endpoints, NOT .convex.cloud.
// ❌ Wrong - .convex.cloud is for dashboard only const url = `${process.env.NEXT_PUBLIC_CONVEX_URL}/uploadFile`; // Results in 404 Not Found // ✅ Correct - replace with .convex.site const url = `${process.env.NEXT_PUBLIC_CONVEX_URL.replace('.convex.cloud', '.convex.site')}/uploadFile`;
Why:
.convex.cloud is for the Convex dashboard, .convex.site is for HTTP endpoints.
Key Files
- convex/schema.ts - Database schema definitions (tables, indexes, relationships)
- convex/http.ts - HTTP endpoint routes and handlers
- convex/_generated/api.js - Generated API types (auto-generated, don't edit)
- convex/auth.config.ts - Authentication configuration (Clerk JWT)
Authentication Pattern
Convex integrates with Clerk via JWT tokens:
// In HTTP action - get auth from header const authHeader = request.headers.get("Authorization"); const token = authHeader?.replace("Bearer ", ""); // In query/mutation - get authenticated user const identity = await ctx.auth.getUserIdentity(); if (!identity) { throw new Error("Unauthorized"); }
Workflows
Workflow 1: Create HTTP Endpoint
Step-by-step guide for creating HTTP endpoints with CORS, authentication, and error handling.
Step 1: Define CORS Headers
// convex/http.ts const CORS_HEADERS = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "POST, OPTIONS, GET, PUT, DELETE", "Access-Control-Allow-Headers": "Content-Type, Authorization", "Vary": "Origin", };
Step 2: Create HTTP Route
import { httpRouter, httpAction } from "convex/server"; import { api } from "./_generated/api"; const http = httpRouter(); http.route({ path: "/your-endpoint", method: "POST", handler: httpAction(async (ctx, request) => { try { // Parse request body const body = await request.json(); // Validate inputs if (!body.requiredField) { return new Response( JSON.stringify({ error: "Missing requiredField" }), { status: 400, headers: CORS_HEADERS } ); } // Call mutation or action const result = await ctx.runMutation(api.mutations.yourMutation, body); // Return success with CORS return new Response( JSON.stringify({ success: true, data: result }), { status: 200, headers: CORS_HEADERS } ); } catch (error) { // Return error with CORS return new Response( JSON.stringify({ error: error.message }), { status: 500, headers: CORS_HEADERS } ); } }), }); export default http;
Step 3: Add OPTIONS Handler (Required for CORS)
http.route({ path: "/your-endpoint", method: "OPTIONS", handler: httpAction(async () => { return new Response(null, { status: 200, headers: CORS_HEADERS, }); }), });
Step 4: Add Authentication (Optional)
http.route({ path: "/authenticated-endpoint", method: "POST", handler: httpAction(async (ctx, request) => { // Get and validate auth token const authHeader = request.headers.get("Authorization"); if (!authHeader?.startsWith("Bearer ")) { return new Response( JSON.stringify({ error: "Missing or invalid Authorization header" }), { status: 401, headers: CORS_HEADERS } ); } // Verify with Clerk (if using Clerk auth) const token = authHeader.replace("Bearer ", ""); // Your authentication logic here // ... // Continue with authenticated logic const body = await request.json(); const result = await ctx.runMutation(api.mutations.secureAction, body); return new Response( JSON.stringify({ success: true, data: result }), { status: 200, headers: CORS_HEADERS } ); }), });
Step 5: Use Endpoint from Frontend
// In your Next.js app const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL!.replace('.convex.cloud', '.convex.site'); const response = await fetch(`${convexUrl}/your-endpoint`, { method: "POST", headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}`, // if authenticated }, body: JSON.stringify({ requiredField: "value" }), }); const data = await response.json();
Workflow 2: Design Database Schema
Step 1: Define Tables in schema.ts
// convex/schema.ts import { defineSchema, defineTable } from "convex/server"; import { v } from "convex/values"; export default defineSchema({ users: defineTable({ clerkId: v.string(), email: v.string(), name: v.optional(v.string()), createdAt: v.number(), }) .index("by_clerkId", ["clerkId"]) .index("by_email", ["email"]), posts: defineTable({ title: v.string(), content: v.string(), authorId: v.id("users"), published: v.boolean(), createdAt: v.number(), updatedAt: v.number(), }) .index("by_author", ["authorId"]) .index("by_published", ["published"]) .index("by_author_and_published", ["authorId", "published"]), });
Step 2: Add Indexes for Queries
Add indexes for fields you'll frequently query:
- Single field indexes:
.index("by_field", ["field"]) - Compound indexes:
.index("by_field1_field2", ["field1", "field2"])
Rule: If you query by a field, add an index for it.
Step 3: Create Queries Using Schema
// convex/posts.ts import { query } from "./_generated/server"; import { v } from "convex/values"; export const getByAuthor = query({ args: { authorId: v.id("users") }, handler: async (ctx, args) => { return await ctx.db .query("posts") .withIndex("by_author", (q) => q.eq("authorId", args.authorId)) .collect(); }, });
Step 4: Deploy Schema Changes
Schema changes deploy automatically with
npx convex dev or when you push to production.
Workflow 3: Implement Scheduled Action Pattern
Use this pattern for async operations like API calls, emails, or background jobs.
Step 1: Create Action for Side Effect
// convex/actions.ts import { action } from "./_generated/server"; import { v } from "convex/values"; export const sendWelcomeEmail = action({ args: { email: v.string(), name: v.string() }, handler: async (ctx, args) => { // Call external email API await fetch("https://api.emailservice.com/send", { method: "POST", headers: { "Authorization": `Bearer ${process.env.EMAIL_API_KEY}` }, body: JSON.stringify({ to: args.email, subject: "Welcome!", body: `Hello ${args.name}, welcome to our app!`, }), }); // Optionally update database via mutation await ctx.runMutation(api.mutations.markEmailSent, { email: args.email, }); }, });
Step 2: Schedule Action from Mutation
// convex/mutations.ts import { mutation } from "./_generated/server"; import { v } from "convex/values"; export const createUser = mutation({ args: { clerkId: v.string(), email: v.string(), name: v.string() }, handler: async (ctx, args) => { // Create user in database const userId = await ctx.db.insert("users", { clerkId: args.clerkId, email: args.email, name: args.name, createdAt: Date.now(), }); // Schedule welcome email (async) await ctx.scheduler.runAfter(0, api.actions.sendWelcomeEmail, { email: args.email, name: args.name, }); return userId; }, });
Timing Options:
- Run immediately (async)runAfter(0, ...)
- Run after 1 minuterunAfter(60000, ...)
- Run at specific timerunAt(timestamp, ...)
Workflow 4: File Storage with Progress
Step 1: Generate Upload URL
// convex/storage.ts import { mutation } from "./_generated/server"; export const generateUploadUrl = mutation(async (ctx) => { return await ctx.storage.generateUploadUrl(); });
Step 2: Upload File from Frontend
// In your Next.js component const uploadFile = async (file: File) => { // Get upload URL const uploadUrl = await convex.mutation(api.storage.generateUploadUrl); // Upload file const result = await fetch(uploadUrl, { method: "POST", headers: { "Content-Type": file.type }, body: file, }); const { storageId } = await result.json(); // Save storage ID to database await convex.mutation(api.mutations.saveFile, { storageId, filename: file.name, contentType: file.type, }); };
Step 3: Get File URL
// convex/queries.ts import { query } from "./_generated/server"; import { v } from "convex/values"; export const getFileUrl = query({ args: { storageId: v.string() }, handler: async (ctx, args) => { // ALWAYS use ctx.storage.getUrl() const url = await ctx.storage.getUrl(args.storageId); return url; }, });
Troubleshooting
Issue: CORS Policy Blocked in Browser
Symptoms:
- Endpoint works in Postman/curl
- Browser console shows CORS error
- Request fails with no response
Cause: Missing CORS headers in HTTP endpoint responses
Solution:
- Add CORS headers constant:
const CORS_HEADERS = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "POST, OPTIONS, GET, PUT, DELETE", "Access-Control-Allow-Headers": "Content-Type, Authorization", "Vary": "Origin", };
- Include CORS headers in ALL responses (success, error, OPTIONS):
return new Response( JSON.stringify({ data }), { status: 200, headers: CORS_HEADERS } );
- Add OPTIONS handler for preflight:
http.route({ path: "/your-endpoint", method: "OPTIONS", handler: httpAction(async () => { return new Response(null, { status: 200, headers: CORS_HEADERS }); }), });
Frequency: High (very common mistake)
Issue: 404 Not Found on HTTP Endpoints
Symptoms:
- Endpoint configured in convex/http.ts
- Dashboard shows endpoint exists
- Frontend gets 404 error
Cause: Using
.convex.cloud domain instead of .convex.site
Solution:
// ❌ Wrong const url = `${process.env.NEXT_PUBLIC_CONVEX_URL}/endpoint`; // ✅ Correct const url = `${process.env.NEXT_PUBLIC_CONVEX_URL.replace('.convex.cloud', '.convex.site')}/endpoint`;
Why:
.convex.cloud is for the Convex dashboard UI, .convex.site is for HTTP endpoints.
Frequency: High (common mistake)
Issue: Storage URL Returns Null
Symptoms:
- File uploaded successfully
- Storage ID exists
- getUrl() returns null
Cause: Manual URL construction instead of using ctx.storage.getUrl()
Solution:
// ❌ Wrong - manual construction const url = `https://deployment.convex.cloud/storage/${storageId}`; // ✅ Correct - use API const url = await ctx.storage.getUrl(storageId);
Frequency: Medium
Issue: 401 Unauthorized on HTTP Endpoints
Symptoms:
- Authentication configured
- Token present in request
- Getting 401 error
Possible Causes & Solutions:
- Missing Bearer prefix:
// ❌ Wrong headers: { "Authorization": token } // ✅ Correct headers: { "Authorization": `Bearer ${token}` }
- Clerk JWT misconfiguration:
- Check
in Convex dashboardCLERK_JWT_ISSUER_DOMAIN - Verify
has correct issuer domainconvex/auth.config.ts - Ensure Clerk JWT template is set up correctly
- Token expired:
- Check token expiration
- Refresh token if needed
Frequency: Medium
Validation Checklist
Before considering Convex implementation complete:
- Functions use correct type (query for reads, mutation for writes, action for side effects)
- HTTP endpoints include CORS headers in all responses
- HTTP endpoints have OPTIONS handler for preflight
- Schema includes indexes for all queried fields
- Async operations use scheduled actions (ctx.scheduler.runAfter)
- Actions are scheduled from mutations, not called directly
- Frontend uses .convex.site domain for HTTP endpoints
- Storage URLs use ctx.storage.getUrl(), not manual construction
- Authentication is properly validated where required
- Error responses include appropriate status codes and CORS headers
Best Practices
- Keep functions focused - Each query/mutation/action should do one thing well
- Use TypeScript strictly - Leverage generated types from convex/values
- Index strategically - Add indexes for fields you query, but don't over-index
- Handle errors gracefully - Always return proper status codes and error messages
- Test locally first - Use
to test before deployingnpx convex dev - Secure sensitive operations - Always validate authentication for protected endpoints
- Use environment variables - Never hardcode API keys or secrets
References
- Previous expertise:
.claude/experts/convex-expert/expertise.yaml - Agent integration:
.claude/agents/agent-convex.md - Official docs: https://docs.convex.dev