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.

install
source · Clone the upstream repo
git clone https://github.com/majiayu000/claude-skill-registry
Claude Code · Install into ~/.claude/skills/
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"
manifest: skills/data/convex-development/SKILL.md
source content

Skill: 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:

  • runAfter(0, ...)
    - Run immediately (async)
  • runAfter(60000, ...)
    - Run after 1 minute
  • runAt(timestamp, ...)
    - Run at specific time

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:

  1. 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",
};
  1. Include CORS headers in ALL responses (success, error, OPTIONS):
return new Response(
  JSON.stringify({ data }),
  { status: 200, headers: CORS_HEADERS }
);
  1. 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:

  1. Missing Bearer prefix:
// ❌ Wrong
headers: { "Authorization": token }

// ✅ Correct
headers: { "Authorization": `Bearer ${token}` }
  1. Clerk JWT misconfiguration:
  • Check
    CLERK_JWT_ISSUER_DOMAIN
    in Convex dashboard
  • Verify
    convex/auth.config.ts
    has correct issuer domain
  • Ensure Clerk JWT template is set up correctly
  1. 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

  1. Keep functions focused - Each query/mutation/action should do one thing well
  2. Use TypeScript strictly - Leverage generated types from convex/values
  3. Index strategically - Add indexes for fields you query, but don't over-index
  4. Handle errors gracefully - Always return proper status codes and error messages
  5. Test locally first - Use
    npx convex dev
    to test before deploying
  6. Secure sensitive operations - Always validate authentication for protected endpoints
  7. 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