← All posts
·19 min read

Claude Code with Convex: Queries, Mutations, TypeScript

Claude CodeConvexBackendTypeScript
Claude Code with Convex: Queries, Mutations, TypeScript

Why Convex without CLAUDE.md ships broken reactivity

Convex is built around a single guarantee: your UI is always consistent with your database. useQuery(api.tasks.list) subscribes to the result and re-renders whenever the underlying data changes. No polling. No manual invalidation. No websocket plumbing. The framework handles it.

Claude Code does not know this guarantee exists unless you tell it. Without a CLAUDE.md in place, Claude reaches for patterns that undercut the whole model. It wraps useQuery in a useEffect and a useState, treating it like a one-shot fetch that needs manual refresh. It writes mutations that read from the database directly instead of calling a query, or actions that write to the database directly instead of calling a mutation. It skips argument validators on mutations because TypeScript inference feels sufficient. It puts external API calls inside queries, where they are forbidden, instead of inside actions.

None of these produce immediate TypeScript errors. They produce runtime failures, stale UI, and silent breakage at the edges of the function type boundaries Convex enforces. The Convex runtime is strict about what each function type can do. Claude is not strict about those boundaries because it has not been told they exist.

This guide gives you the CLAUDE.md configuration that anchors Claude Code to how Convex actually works: reactive queries that are never wrapped in useEffect, mutations that validate every argument, actions that are the only place external calls live, and a schema that is the single source of truth for every table in the database. If you are comparing backend options, Claude Code with Supabase and Claude Code with Firebase cover the equivalent setups for those platforms. For the ORM layer that sometimes pairs with Convex on the relational side, Claude Code with Drizzle is worth reading alongside this.

The Convex CLAUDE.md template

The CLAUDE.md at your project root is read at the start of every Claude Code session. For a Convex project it needs to declare: the convex/ directory structure and what lives where, the three function types and their exact permissions, the validator syntax for every mutation and query that takes arguments, the useQuery rules that govern the React layer, the auth pattern, the file storage pattern, and the hard rules that block the failure modes Claude generates most often without guidance.

# Convex backend rules

## Stack
- Convex 1.x, TypeScript 5.x strict
- React 18.x with @tanstack/react-query optional
- Next.js 14 or Vite 5 frontend (adjust as needed)
- Clerk for auth (or Auth.js , see auth section)

## Project structure
- convex/                , Convex source of truth (all backend functions)
- convex/schema.ts       , Table definitions and index declarations . ALWAYS edit here first
- convex/queries/        , Read-only reactive query functions
- convex/mutations/      , Write functions that modify database state
- convex/actions/        , Functions that call external APIs or services
- convex/_generated/     , Auto-generated types . NEVER edit manually
- convex/helpers/        , Shared utilities (no DB access, pure functions only)

## Function type rules (STRICT, no exceptions)

### Queries (convex/queries/*.ts)
- Defined with query({ args, handler }) from "convex/server"
- Handler receives (ctx, args) where ctx has ctx.db (read only) and ctx.auth
- NEVER mutate the database in a query
- NEVER call ctx.storage in a query
- NEVER use fetch() or call external APIs in a query
- NEVER call a mutation or action from inside a query
- Queries run in a reactive subscription . Convex re-runs them when dependencies change

### Mutations (convex/mutations/*.ts)
- Defined with mutation({ args, handler }) from "convex/server"
- Handler receives (ctx, args) where ctx has ctx.db (read/write) and ctx.auth
- ALWAYS validate args with v.* validators (see validator rules below)
- NEVER use fetch() or external APIs in a mutation . Use an action instead
- NEVER call an action from inside a mutation
- Can call ctx.db.insert / ctx.db.patch / ctx.db.delete / ctx.db.replace
- Can call other queries via ctx.runQuery (read-only, no side effects)

### Actions (convex/actions/*.ts)
- Defined with action({ args, handler }) from "convex/server"
- Handler receives (ctx, args) where ctx has ctx.runQuery, ctx.runMutation, ctx.storage, ctx.auth
- NEVER access ctx.db directly in an action . Call ctx.runQuery or ctx.runMutation
- Use fetch() here for external API calls (Stripe, OpenAI, email, etc.)
- Actions are NOT reactive and do NOT subscribe to data changes
- Schedule actions with ctx.scheduler.runAfter() for deferred work

## Validator rules (MANDATORY on every mutation and query with args)
- ALWAYS use v.* validators . They are the type contract and runtime guard
- v.string()                     , string field
- v.number()                     , number field
- v.boolean()                    , boolean field
- v.id("tableName")              , Convex document ID for a specific table
- v.optional(v.string())         , nullable / optional field
- v.array(v.string())            , array of strings
- v.object({ key: v.string() })  , nested object with typed fields
- v.union(v.literal("a"), v.literal("b"))  , discriminated union
- v.null()                       , explicitly null value
- NEVER skip validators on the assumption TypeScript inference is sufficient
- The validator IS the runtime type check . TypeScript is stripped at runtime

## Schema rules (convex/schema.ts)
- ALWAYS define new tables in schema.ts before writing queries or mutations for them
- Use defineSchema() and defineTable() from "convex/server"
- Define indexes with .index("by_fieldName", ["fieldName"]) on the table
- NEVER query by a field that does not have an index (use withIndex, not filter, for indexed fields)
- System fields (_id, _creationTime) are auto-added . Do NOT declare them in schema
- Use v.id("tableName") for foreign key references between tables

## Example schema.ts
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";

export default defineSchema({
  tasks: defineTable({
    title: v.string(),
    description: v.optional(v.string()),
    completed: v.boolean(),
    userId: v.string(),
    priority: v.union(v.literal("low"), v.literal("medium"), v.literal("high")),
  })
    .index("by_user", ["userId"])
    .index("by_user_and_completed", ["userId", "completed"]),

  comments: defineTable({
    taskId: v.id("tasks"),
    authorId: v.string(),
    body: v.string(),
  }).index("by_task", ["taskId"]),
});

## React hook rules

### useQuery
- ALWAYS: const tasks = useQuery(api.queries.tasks.list, { userId });
- NEVER wrap useQuery in useEffect . It is reactive by default
- NEVER copy useQuery result into useState for "caching" . It is always current
- NEVER add a dependency array to useQuery . There is no second argument for deps
- useQuery returns undefined while loading, null when no record, or the data
- ALWAYS handle the undefined (loading) case before rendering

### useMutation
- const createTask = useMutation(api.mutations.tasks.create);
- Call it as an async function: await createTask({ title, userId });
- Wrap calls in try/catch . Convex mutations throw on validation failure
- NEVER fire-and-forget a mutation that can fail . Always await or .catch()

### useAction
- const runWebhook = useAction(api.actions.webhooks.processStripe);
- Same call pattern as useMutation
- Actions are for one-shot side effects, NOT for fetching data reactively

## Auth pattern (Clerk)
- Configure withClerk in convex/auth.config.ts
- In handler: const identity = await ctx.auth.getUserIdentity();
- ALWAYS check identity !== null before accessing identity.subject
- identity.subject is the Clerk userId. Use this as the userId field in tables
- NEVER store auth tokens or session data in Convex tables
- NEVER trust client-supplied userId args . Always derive from ctx.auth

## File storage pattern
- Generate upload URL in a mutation: const url = await ctx.storage.generateUploadUrl();
- Client uploads directly to the URL via fetch (multipart/form-data)
- Client calls a mutation passing the storageId returned from the upload
- Retrieve file URL: await ctx.storage.getUrl(storageId)
- Store storageId (v.string()) in your table, not the full URL
- NEVER store the generated upload URL . It is single-use and expires

## Deployment
- npx convex dev       , local dev with hot reload
- npx convex deploy    , deploy to production
- NEVER run npx convex deploy from CI without CONVEX_DEPLOY_KEY in env
- Generated types in convex/_generated/ are committed to the repo
- Do NOT gitignore convex/_generated/ . It is needed by TypeScript

## Hard rules
- NEVER write to the database inside a query
- NEVER call fetch() inside a query or mutation
- NEVER skip arg validators on mutations or queries with arguments
- NEVER call useQuery inside useEffect with a manual setState
- NEVER use the _id field as a plain string . Always use v.id("table") validator
- NEVER import directly from convex/_generated/server . Import from "convex/server"
- ALWAYS check getUserIdentity() result for null before using it
- ALWAYS use withIndex() for indexed field lookups, never .filter() alone on indexed fields

Three rules here prevent the majority of bugs Claude generates without them.

The never-useQuery-in-useEffect rule is the most important. Claude's training data contains thousands of examples of useEffect(() => { fetchData(); }, []) for data fetching. Without instruction, it applies that pattern to Convex: wrapping useQuery in a useEffect, copying the result to useState, and manually refreshing it on some dependency change. This destroys Convex's reactivity. The data goes stale the moment the database changes, and no UI update follows. The correct model is that useQuery(api.queries.tasks.list) is the subscription. It re-renders the component every time the backing data changes, with no useEffect required.

The always-validate-args rule matters because validators are not redundant with TypeScript types. TypeScript inference is stripped at compile time. The Convex validator runs at runtime on the server. A mutation with no args validator accepts any input that reaches the server, including malformed or malicious payloads. A mutation with args: { taskId: v.id("tasks"), title: v.string() } rejects any call where taskId is not a valid Convex ID for the tasks table, at the server boundary, before the handler runs.

The never-fetch-in-mutation rule closes the biggest misuse pattern. Claude knows that mutations write to the database. It also knows that Stripe webhook processing writes to the database. It combines these two pieces of knowledge and puts the fetch() call inside the mutation. Convex forbids this: mutations cannot use fetch. The correct structure is an action that calls the external API and then calls ctx.runMutation() with the result. Declaring this as a hard rule in CLAUDE.md means Claude generates the correct two-function structure from the start.

convex/ folder layout and schema.ts

The convex/ directory is Convex's source of truth. Every function Claude generates must live inside it. The generated types in convex/_generated/ are produced by npx convex dev and must not be edited by hand. Schema.ts is the file that defines every table in the database.

A well-structured convex/ layout:

convex/
  schema.ts              # table definitions and indexes
  queries/
    tasks.ts             # export const list = query({...})
    comments.ts
    users.ts
  mutations/
    tasks.ts             # export const create = mutation({...})
    comments.ts
  actions/
    stripe.ts            # export const processWebhook = action({...})
    openai.ts
    email.ts
  helpers/
    formatting.ts        # pure utility functions, no ctx access
  _generated/
    api.d.ts             # auto-generated . Never edit
    server.d.ts          # auto-generated . Never edit
  auth.config.ts         # auth provider configuration

The key convention: queries, mutations, and actions are separated into subdirectories, not mixed in a flat convex/ root. Claude will generate a flat layout by default (convex/tasks.ts with all three function types in one file). The subdirectory separation makes function type boundaries visible in the file system, which helps both Claude and human developers maintain the correct rules for each type.

Schema.ts must be edited before writing any query or mutation for a new table. Claude sometimes generates a query for a table that does not exist in schema.ts yet, relying on Convex's soft schema mode. That works in development but fails in production with schema enforcement enabled. The CLAUDE.md rule requiring schema.ts edits first prevents this gap.

Schema patterns with v.object validation

Schema.ts patterns that Claude needs to generate correctly:

// convex/schema.ts
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";

export default defineSchema({
  // Simple table with union type and optional field
  tasks: defineTable({
    title: v.string(),
    body: v.optional(v.string()),
    status: v.union(
      v.literal("todo"),
      v.literal("in_progress"),
      v.literal("done")
    ),
    userId: v.string(),
    assigneeId: v.optional(v.id("users")),
    tags: v.array(v.string()),
  })
    .index("by_user", ["userId"])
    .index("by_status", ["status"]),

  // Table with a nested object field
  documents: defineTable({
    title: v.string(),
    metadata: v.object({
      wordCount: v.number(),
      language: v.string(),
      isPublished: v.boolean(),
    }),
    ownerId: v.string(),
  }).index("by_owner", ["ownerId"]),

  // Table referencing another table by ID
  comments: defineTable({
    taskId: v.id("tasks"),
    authorId: v.string(),
    body: v.string(),
    edited: v.optional(v.boolean()),
  }).index("by_task", ["taskId"]),

  // File storage table (storageId is a string)
  uploads: defineTable({
    storageId: v.string(),
    fileName: v.string(),
    fileSize: v.number(),
    mimeType: v.string(),
    uploadedBy: v.string(),
  }).index("by_uploader", ["uploadedBy"]),
});

The v.id("tasks") validator on comments.taskId is what Claude most often generates incorrectly. Without the rule, Claude uses v.string() for every foreign key field. v.string() accepts any string. v.id("tasks") accepts only a valid Convex document ID that belongs to the tasks table. The runtime rejects invalid references before the handler runs.

The v.union(v.literal(...)) pattern for status fields is another common gap. Claude reaches for v.string() for enum-like fields. The union of literals gives Convex (and TypeScript) the discriminated union type, making exhaustive switching possible in handlers and making the schema self-documenting.

Query reactivity and the useQuery contract

Queries in Convex are reactive subscriptions, not one-shot fetches. Understanding this changes how Claude should write the React layer.

Correct query function in convex/queries/tasks.ts:

import { query } from "convex/server";
import { v } from "convex/values";

export const list = query({
  args: {
    userId: v.string(),
    status: v.optional(
      v.union(v.literal("todo"), v.literal("in_progress"), v.literal("done"))
    ),
  },
  handler: async (ctx, args) => {
    const identity = await ctx.auth.getUserIdentity();
    if (!identity) {
      throw new Error("Not authenticated");
    }
    if (identity.subject !== args.userId) {
      throw new Error("Unauthorized");
    }

    const q = ctx.db
      .query("tasks")
      .withIndex("by_user", (q) => q.eq("userId", args.userId));

    if (args.status) {
      return await q.filter((q) => q.eq(q.field("status"), args.status)).collect();
    }

    return await q.collect();
  },
});

Correct React usage:

// CORRECT: useQuery is the subscription, no useEffect needed
const tasks = useQuery(api.queries.tasks.list, { userId: user.id });

if (tasks === undefined) return <LoadingSpinner />;
if (tasks === null) return <EmptyState />;
return <TaskList tasks={tasks} />;

The three-state return from useQuery is another pattern Claude misses without explicit guidance. undefined means the query is still loading. null means the query returned null (no document found). The actual data is the third state. Claude often treats undefined and null interchangeably, collapsing them into a single if (!tasks) check that masks loading and empty states.

The withIndex call on the query is also important. Querying a table without using an index forces a full table scan in Convex. The schema defines indexes, and queries should use them via withIndex. Claude will generate .filter((q) => q.eq(q.field("userId"), args.userId)) without the index, which works in development and degrades under load.

Mutation argument validation

Every mutation that accepts arguments needs a validator. This is a hard rule, not a preference.

// convex/mutations/tasks.ts
import { mutation } from "convex/server";
import { v } from "convex/values";

export const create = mutation({
  args: {
    title: v.string(),
    body: v.optional(v.string()),
    status: v.optional(
      v.union(v.literal("todo"), v.literal("in_progress"), v.literal("done"))
    ),
    tags: v.optional(v.array(v.string())),
  },
  handler: async (ctx, args) => {
    const identity = await ctx.auth.getUserIdentity();
    if (!identity) throw new Error("Not authenticated");

    return await ctx.db.insert("tasks", {
      title: args.title,
      body: args.body,
      status: args.status ?? "todo",
      userId: identity.subject,
      tags: args.tags ?? [],
    });
  },
});

export const update = mutation({
  args: {
    id: v.id("tasks"),
    title: v.optional(v.string()),
    status: v.optional(
      v.union(v.literal("todo"), v.literal("in_progress"), v.literal("done"))
    ),
  },
  handler: async (ctx, args) => {
    const identity = await ctx.auth.getUserIdentity();
    if (!identity) throw new Error("Not authenticated");

    const task = await ctx.db.get(args.id);
    if (!task) throw new Error("Task not found");
    if (task.userId !== identity.subject) throw new Error("Unauthorized");

    const { id, ...patch } = args;
    await ctx.db.patch(id, patch);
  },
});

export const remove = mutation({
  args: { id: v.id("tasks") },
  handler: async (ctx, args) => {
    const identity = await ctx.auth.getUserIdentity();
    if (!identity) throw new Error("Not authenticated");

    const task = await ctx.db.get(args.id);
    if (!task) throw new Error("Task not found");
    if (task.userId !== identity.subject) throw new Error("Unauthorized");

    await ctx.db.delete(args.id);
  },
});

The v.id("tasks") validator on update and remove does two things: it validates that the value is a Convex document ID (not any arbitrary string), and it scopes that ID to the tasks table. A client passing an ID from the comments table to the update mutation fails at the validator boundary, not in the handler after a confusing ctx.db.get miss.

The auth check on every mutation is the other pattern Claude skips without a hard rule. Claude will generate mutations that accept a userId argument and trust it, rather than deriving the user identity from ctx.auth.getUserIdentity(). A client can pass any userId it wants. The server identity from ctx.auth cannot be spoofed.

Actions for external API calls

Actions are the only Convex function type that can use fetch. They cannot access ctx.db directly. They read data by calling ctx.runQuery and write data by calling ctx.runMutation.

// convex/actions/stripe.ts
import { action } from "convex/server";
import { v } from "convex/values";
import Stripe from "stripe";

export const createCheckoutSession = action({
  args: {
    priceId: v.string(),
    successUrl: v.string(),
    cancelUrl: v.string(),
  },
  handler: async (ctx, args) => {
    const identity = await ctx.auth.getUserIdentity();
    if (!identity) throw new Error("Not authenticated");

    // OK: fetch is allowed in actions
    const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

    // OK: ctx.runQuery to read from DB before the external call
    const user = await ctx.runQuery(api.queries.users.getByClerkId, {
      clerkId: identity.subject,
    });
    if (!user) throw new Error("User not found");

    const session = await stripe.checkout.sessions.create({
      customer_email: user.email,
      line_items: [{ price: args.priceId, quantity: 1 }],
      mode: "payment",
      success_url: args.successUrl,
      cancel_url: args.cancelUrl,
    });

    // OK: ctx.runMutation to write the result back to DB
    await ctx.runMutation(api.mutations.orders.createPending, {
      userId: identity.subject,
      stripeSessionId: session.id,
    });

    return { sessionUrl: session.url };
  },
});

Claude's most common action mistake: writing ctx.db.insert(...) directly inside an action handler. This throws at runtime because ctx.db does not exist in the action context. The action must call ctx.runMutation with a mutation that does the insert. Declaring this explicitly in CLAUDE.md as "NEVER access ctx.db directly in an action" prevents the error.

The second common mistake: putting Stripe or OpenAI calls inside a mutation. Mutations cannot use fetch. Claude sometimes generates this because "the mutation writes to the database, and the Stripe call result is what we're writing." The correct structure is always: action calls external API, action calls mutation with the result, mutation writes to database.

File storage with ctx.storage

Convex file storage is a three-step process: generate an upload URL (mutation), upload the file directly from the client to that URL (client-side fetch), then save the returned storage ID to the database (mutation).

// convex/mutations/uploads.ts
import { mutation } from "convex/server";
import { v } from "convex/values";

// Step 1: generate a one-use upload URL
export const generateUploadUrl = mutation({
  args: {},
  handler: async (ctx) => {
    const identity = await ctx.auth.getUserIdentity();
    if (!identity) throw new Error("Not authenticated");
    return await ctx.storage.generateUploadUrl();
  },
});

// Step 3: save the storage ID after client upload completes
export const saveUpload = mutation({
  args: {
    storageId: v.string(),
    fileName: v.string(),
    fileSize: v.number(),
    mimeType: v.string(),
  },
  handler: async (ctx, args) => {
    const identity = await ctx.auth.getUserIdentity();
    if (!identity) throw new Error("Not authenticated");

    return await ctx.db.insert("uploads", {
      storageId: args.storageId,
      fileName: args.fileName,
      fileSize: args.fileSize,
      mimeType: args.mimeType,
      uploadedBy: identity.subject,
    });
  },
});

// Retrieve the URL for a stored file
export const getFileUrl = query({
  args: { storageId: v.string() },
  handler: async (ctx, args) => {
    return await ctx.storage.getUrl(args.storageId);
  },
});

Client-side upload component:

const generateUploadUrl = useMutation(api.mutations.uploads.generateUploadUrl);
const saveUpload = useMutation(api.mutations.uploads.saveUpload);

const handleUpload = async (file: File) => {
  // Step 1: get the upload URL
  const uploadUrl = await generateUploadUrl();

  // Step 2: upload directly to Convex storage
  const response = await fetch(uploadUrl, {
    method: "POST",
    headers: { "Content-Type": file.type },
    body: file,
  });
  const { storageId } = await response.json();

  // Step 3: save the storageId to our table
  await saveUpload({
    storageId,
    fileName: file.name,
    fileSize: file.size,
    mimeType: file.type,
  });
};

The mistake Claude makes without guidance: storing the upload URL itself in the database instead of the storageId. The upload URL is single-use and expires. The storageId is permanent. Calling ctx.storage.getUrl(storageId) at query time always returns a fresh, valid URL. Storing the upload URL returns a dead link after expiry.

Auth via Clerk and ctx.auth

Convex integrates with Clerk, Auth.js, and other providers through a JWT-based identity system. The handler receives identity through ctx.auth.getUserIdentity(), which returns null when the request is unauthenticated and an identity object when authenticated.

// convex/auth.config.ts (Clerk configuration)
export default {
  providers: [
    {
      domain: "https://your-clerk-domain.clerk.accounts.dev",
      applicationID: "convex",
    },
  ],
};

Identity access pattern in every handler that requires auth:

const identity = await ctx.auth.getUserIdentity();
if (!identity) {
  throw new Error("Not authenticated");
}

// identity.subject is the stable user ID (Clerk userId)
// identity.email is the email (if configured in Clerk JWT template)
// identity.name is the display name (if configured)
const userId = identity.subject;

The critical rule Claude skips: never trust a userId argument passed by the client. Always derive it from ctx.auth.getUserIdentity().subject. Claude will write mutations that accept args: { userId: v.string(), ... } and use that as the owner of created records. Any client can pass any userId. The server identity is the only trustworthy source.

The JWT template configuration in Clerk controls which fields appear on the identity object. If identity.email returns undefined in a handler, the Clerk JWT template for Convex needs to include the email claim. Add this to CLAUDE.md if email is needed in handlers, so Claude does not generate code that relies on fields that are not in the JWT template.

Deployment with npx convex deploy

Convex deployment is a two-step process that Claude sometimes collapses incorrectly.

## Deployment procedure

### Local development
npx convex dev
- Watches convex/ for changes
- Pushes function updates automatically
- Generates convex/_generated/ types on every change
- Never run in CI

### Production deployment
CONVEX_DEPLOY_KEY=your-key npx convex deploy
- Deploys all functions to the production Convex deployment
- Runs in CI after tests pass
- The deploy key is per-deployment . Never share between dev and prod

### Environment variables in Convex functions
npx convex env set STRIPE_SECRET_KEY sk_live_...
- Set via CLI or Convex dashboard, never hardcode
- Access in handlers via process.env.STRIPE_SECRET_KEY
- Different values for dev and prod deployments (set separately)

### What gets committed to the repo
- convex/schema.ts           , YES, source of truth
- convex/queries/*.ts        , YES, source of truth
- convex/mutations/*.ts      , YES, source of truth
- convex/actions/*.ts        , YES, source of truth
- convex/_generated/         , YES. Commit this, TypeScript needs it
- .env.local                 , NO. Contains CONVEX_DEPLOYMENT (local URL)

The convex/_generated/ directory is the most common gitignore mistake. Claude sometimes adds it to .gitignore because it is auto-generated. That breaks TypeScript imports for every developer who clones the repo without running npx convex dev first. The generated types are lightweight and safe to commit. The .env.local with the deployment URL is what should not be committed.

Common Claude mistakes without CLAUDE.md

A summary of the failure patterns and their fixes:

Writing a query that mutates. Claude generates ctx.db.insert(...) inside a query() function. The Convex runtime rejects this. Fix: move the insert to a mutation() function.

Using useQuery inside useEffect. Claude wraps useQuery in useEffect(() => { setTasks(useQuery(...)); }, []). This breaks reactivity and misuses React hooks rules. Fix: call useQuery at the component top level, handle undefined for loading state.

Calling fetch() inside a mutation. Claude puts a Stripe or OpenAI fetch call inside a mutation() handler. The Convex runtime rejects network calls in mutations. Fix: move the fetch to an action(), have the action call the mutation with the result.

Using _id as a plain string arg. Claude generates args: { taskId: v.string() } for a task reference. Fix: use args: { taskId: v.id("tasks") } so the validator enforces both format and table scope.

Skipping auth checks on mutations. Claude generates mutations that accept a userId argument and trust it. Fix: use ctx.auth.getUserIdentity() and ignore any client-supplied userId.

Querying without an index. Claude generates .filter((q) => q.eq(q.field("userId"), id)) without a corresponding .withIndex() call. Fix: add the index to schema.ts and use .withIndex("by_user", (q) => q.eq("userId", id)) in the query.

Storing the upload URL instead of the storageId. Claude saves ctx.storage.generateUploadUrl() result to the database. Fix: the upload URL expires, store only the storageId returned after the client upload, and call ctx.storage.getUrl(storageId) at read time.

These patterns are all invisible during development on a small dataset with a fast connection. They surface in production under load, with stale auth tokens, and with real file expiry cycles. The CLAUDE.md template prevents them at generation time. For the broader patterns of building reliable TypeScript backends with Claude Code, Claude Code with tRPC and Claude Code with Next.js cover the API and frontend layers that commonly sit above a Convex backend.

Getting started

The CLAUDE.md template in this guide covers the rules Claude needs to generate correct Convex code: the function type separation, the validator requirement, the auth pattern, the useQuery contract, and the file storage steps. Drop it in your project root before your first Claude Code session, and update the stack section to match your Convex version and auth provider.

For a production Convex project, the CLAUDE.md is one file. The schema.ts is the other. Start every new feature by defining the table in schema.ts, commit the schema, let npx convex dev regenerate the types, then ask Claude to write the queries and mutations. That order guarantees Claude generates functions for tables that actually exist, with indexes that are already declared.

Claudify includes a Convex-specific CLAUDE.md template pre-configured for the validator rules, auth patterns, file storage steps, and function type separation shown here, alongside templates for the other backend platforms and frameworks covered in the Claudify library.

More like this

Ready to upgrade your Claude Code setup?

Get Claudify
Featured on Dofollow.Tools AI Toolz Dir Claudify - Featured on Startup Fame