◆ Convex Stack Bootcamp

Convex API cheatsheet

Written against the live Convex docs, not from memory. Where this file and a video disagree, this file is closer to current — but the docs themselves are canonical, so check https://docs.convex.dev before blaming your code.


#The three function types

This is the whole mental model. Everything else is detail.

Reads DBWrites DBCalls the outside worldReactiveRetried automatically
query✅❌❌✅✅ (deterministic)
mutation✅✅❌— (triggers query updates)✅ (transactional)
actionvia ctx.runQueryvia ctx.runMutation✅❌❌

The rule that generates the whole table: queries and mutations are deterministic transactions, so they cannot touch the network. Actions can touch the network, so they cannot be transactional or reactive. Everything else follows.

Each has an internal* twin — internalQuery, internalMutation, internalAction — callable only from inside Convex, never from a client.

import { query, mutation, action, internalMutation } from "./_generated/server";
import { v } from "convex/values";

export const myQuery = query({
  args: { a: v.number(), b: v.number() },
  returns: v.number(),
  handler: (ctx, args) => args.a + args.b,
});

args validates input. returns validates output — optional, and worth adding on anything a client depends on.

#Validators (v from convex/values)

v.id("tableName")   v.null()      v.int64()     v.number()    v.boolean()
v.string()          v.bytes()     v.commitTs()  v.any()
v.array(inner)      v.object({})  v.record(k, val)
v.union(a, b)       v.literal(x)  v.optional(inner)           v.nullable(inner)

v.number() is a Float64. Use v.int64() when you actually need a BigInt. v.nullable(x) is shorthand for v.union(x, v.null()).

#Reading

// Single document — NOTE the table name as first argument
const task = await ctx.db.get("tasks", args.taskId);

// Query builder
await ctx.db.query("users").take(5);
await ctx.db.query("counter").unique();          // throws if not exactly one
await ctx.db.query("messages").order("desc").collect();

await ctx.db
  .query("messages")
  .withIndex("by_channel", (q) => q.eq("channel", channel))
  .first();

#Writing

All four take the table name first.

const taskId = await ctx.db.insert("tasks", { text: args.text });
await ctx.db.patch("tasks", id, { tag: "bar" });   // shallow merge
await ctx.db.replace("tasks", id, { invalid: true }); // full overwrite
await ctx.db.delete("tasks", args.id);

The single-argument form (ctx.db.get(id)) is what nearly every video tutorial shows. The docs now document the table-name form. If you are copying from a 2024–2025 video and TypeScript complains, this is almost certainly why.

#Schema

convex/schema.ts:

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

export default defineSchema({
  messages: defineTable({
    body: v.string(),
    user: v.id("users"),
  }),
  users: defineTable({
    name: v.string(),
    tokenIdentifier: v.string(),
  }).index("by_token", ["tokenIdentifier"]),
});

Index naming convention is by_<field> / by_<field>_and_<field>. Field order in a composite index matters: by_team_and_user can serve a query on team alone, so a separate by_team index is redundant and should be deleted.

#Indexes vs. filter

// ❌ scans, then discards
ctx.db.query("messages").filter((q) => q.eq(q.field("author"), "Tom")).collect();

// ✅ seeks
await ctx.db.query("messages")
  .withIndex("by_author", (q) => q.eq("author", "Tom"))
  .collect();

.filter() is not wrong — it is a post-scan filter. On a table of 50 rows nobody notices. On 500,000 rows it is the bug.

Also avoid unbounded .collect(). Bound it with an index, .take(n), or .paginate().

#Actions

"use node"; // only if you need Node APIs or an npm package the Convex runtime lacks

export const doSomething = action({
  args: { /* ... */ },
  handler: async (ctx, args) => {
    const data = await ctx.runQuery(internal.module.getThing, { id: args.id });
    const res = await fetch("https://api.example.com");
    await ctx.runMutation(internal.module.saveThing, { value: await res.json() });
  },
});

#Auth

const identity = await ctx.auth.getUserIdentity();
if (identity === null) throw new Error("Unauthorized");

Never trust an identifying value passed in as an argument (an email, a userId). Arguments are attacker-controlled; ctx.auth is not.

Current options: Clerk (strongest Next.js / React Native story), WorkOS AuthKit (B2B-oriented, free to 1M users), Auth0 (most established). Convex Auth is the quickest to stand up and requires no third-party signup, but the docs describe it as in beta — "it isn't complete and may change in backward-incompatible ways" — with Next.js support under active development. Pick accordingly.

#Next.js App Router

// Server Component — reactive after hydration
const preloadedTasks = await preloadQuery(api.tasks.list, { list: "default" });
// ...then in a Client Component:
const tasks = usePreloadedQuery(props.preloadedTasks);

// Non-reactive one-shot read
const tasks = await fetchQuery(api.tasks.list, { list: "default" });

// In a Server Action
await fetchMutation(api.tasks.create, { text: formData.get("text") as string });

// Authenticated server-side call
const preloaded = await preloadQuery(api.tasks.list, { list: "default" }, { token });

With Clerk the token is (await auth()).getToken() ?? undefined. With Auth0 it is session.tokenSet.idToken.

preloadQuery → usePreloadedQuery gives you SSR and live updates. fetchQuery gives you SSR and then nothing — the data is frozen. Choosing fetchQuery when you wanted preloadQuery produces a page that looks fine and silently never updates.

#React client

const convex = new ConvexReactClient("https://<your-deployment>.convex.cloud");
<ConvexProvider client={convex}><App /></ConvexProvider>

const data = useQuery(api.functions.myQuery, { args });
const doSomething = useMutation(api.functions.doSomething);
const doAction = useAction(api.functions.doSomeAction);

useQuery returns undefined while loading. There is no isLoading. And because a query returning undefined is translated to null on the client, undefined is unambiguously "still loading" — that is the whole reason for the translation.

#Scheduling and crons

await ctx.scheduler.runAfter(0, internal.tasks.process, { id });
await ctx.scheduler.runAt(timestamp, internal.tasks.process, { id });

crons.daily(
  "send daily reminder",
  { hourUTC: 17, minuteUTC: 30 },
  internal.messages.sendInternalMessage,
  { author: "System", body: "Share your daily update!" },
);

Always schedule internal.*, never api.*. Scheduling a public function means anyone on the internet can call the thing your cron calls.

#Components

Sandboxed sub-backends with their own tables, installed from npm:

// convex/convex.config.ts
import { defineApp } from "convex/server";
import agent from "@convex-dev/agent/convex.config.js";

const app = defineApp();
app.use(agent);
export default app;
import { components } from "./_generated/api.js";
await ctx.runQuery(components.agent.threads.getThread, { /* ... */ });

A component cannot read your tables unless you hand it the data. Directory: https://convex.dev/components.

#The model-layer pattern

Keep public functions thin; put business logic in plain TypeScript.

// convex/model/users.ts — a normal function, trivially unit-testable
export async function getCurrentUser(ctx: QueryCtx) {
  const identity = await ctx.auth.getUserIdentity();
  if (identity === null) throw new Error("Unauthorized");
  return await loadUserByToken(ctx, identity.tokenIdentifier);
}

// convex/users.ts — the public surface
export const getCurrentUser = query({
  args: {},
  handler: async (ctx) => Users.getCurrentUser(ctx),
});

This is the difference between a Convex codebase that stays pleasant at 10k lines and one that becomes a pile of handlers.