Realtime Backends with Convex: Schemas, Queries vs Mutations, Actions and React Hooks

Key takeaways

Convex is a backend where your TypeScript functions run next to a transactional document database and React components subscribe to query results. This guide explains the query, mutation and action split, the rules behind it, and the mistakes that cost performance or correctness.

The idea behind Convex

Convex is a hosted backend that combines three things: a transactional document database, a runtime where your TypeScript server functions run next to that database, and a sync protocol that keeps clients subscribed to query results. You write functions in a convex/ folder. The CLI deploys them and generates typed references (api.posts.list), and React components call those functions through hooks.

The part that changes how you build apps is reactivity. When a component runs useQuery(api.posts.list), Convex records which documents and index ranges that query read. When a mutation later changes any of them, Convex re-runs the query and pushes the new result over the client’s WebSocket. You do not write invalidation logic, polling, or a separate realtime channel. Every screen showing that list updates.

That guarantee depends on rules that surprise people coming from Express or Next.js API routes. The rules are the reason for Convex’s three function types, so it is worth understanding them before writing code:

FunctionCan read DBCan write DBCan call fetch / external APIsRuns as
queryyesnonodeterministic, cached, reactive
mutationyesyesnoa transaction, retried on conflict
actionvia runQueryvia runMutationyesordinary code, not a transaction

Queries must be deterministic so Convex can cache them and re-run them when their inputs change. Mutations are transactions with optimistic concurrency. If two mutations touch the same documents at the same time, one is re-run against the new state. So a mutation must be safe to run more than once, and it cannot do anything outside the database, such as sending an email. Actions exist for exactly that outside work.

Project setup

npm create convex@latest
# or, in an existing app:
npm install convex
npx convex dev

npx convex dev logs you in, creates a development deployment, writes its URL to .env.local, and keeps running. It watches convex/, type-checks your functions, pushes them on every save, and regenerates convex/_generated/. Keep it running while you work. If generated types look stale or api.something does not exist, the usual cause is that this process is not running or has stopped on a type error.

Schema

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

export default defineSchema({
  users: defineTable({
    tokenIdentifier: v.string(),       // from the auth provider
    name: v.string(),
    email: v.optional(v.string()),
  }).index('by_token', ['tokenIdentifier']),

  posts: defineTable({
    title: v.string(),
    content: v.string(),
    authorId: v.id('users'),
    published: v.boolean(),
  })
    .index('by_author', ['authorId'])
    .index('by_published', ['published']),

  files: defineTable({
    storageId: v.id('_storage'),
    ownerId: v.id('users'),
  }),
});

Every document automatically gets _id and _creationTime, so a hand-written createdAt: Date.now() field is usually redundant. v.id('users') is a typed reference: passing a string that is not a valid id for that table fails argument validation before your handler runs. The schema is enforced when it is deployed. If existing documents do not match a new validator, npx convex dev refuses to push it. That is why schema changes should be additive first (add v.optional(...)), then backfilled, then tightened.

Queries: use indexes, not filter

// convex/posts.ts
import { query } from './_generated/server';
import { v } from 'convex/values';

export const listPublished = query({
  args: {},
  handler: async (ctx) => {
    return await ctx.db
      .query('posts')
      .withIndex('by_published', (q) => q.eq('published', true))
      .order('desc')
      .take(50);
  },
});

export const byAuthor = query({
  args: { authorId: v.id('users') },
  handler: async (ctx, { authorId }) => {
    return await ctx.db
      .query('posts')
      .withIndex('by_author', (q) => q.eq('authorId', authorId))
      .collect();
  },
});

The difference between .withIndex(...) and .filter(...) is the most important performance detail in Convex. .filter does not use an index. It scans documents in the table and discards the ones that do not match, so it gets slower as the table grows, and it counts every scanned document against the function’s read limits. .withIndex reads only the matching range. The scan also affects reactivity: a query that scanned the whole table depends on the whole table, so any write anywhere in it re-runs the query for every subscriber.

The other habit to drop is .collect() on an unbounded query. It loads every matching document into memory. Use .take(n) for “latest N”, and .paginate() with usePaginatedQuery for lists users scroll through.

Mutations and authorization

import { mutation } from './_generated/server';
import { v } from 'convex/values';

export const create = mutation({
  args: { title: v.string(), content: v.string() },
  handler: async (ctx, args) => {
    const identity = await ctx.auth.getUserIdentity();
    if (!identity) throw new Error('Not authenticated');

    const user = await ctx.db
      .query('users')
      .withIndex('by_token', (q) => q.eq('tokenIdentifier', identity.tokenIdentifier))
      .unique();
    if (!user) throw new Error('User not found');

    return await ctx.db.insert('posts', { ...args, authorId: user._id, published: false });
  },
});

export const rename = mutation({
  args: { id: v.id('posts'), title: v.string() },
  handler: async (ctx, { id, title }) => {
    const identity = await ctx.auth.getUserIdentity();
    if (!identity) throw new Error('Not authenticated');
    const post = await ctx.db.get(id);
    const user = await ctx.db
      .query('users')
      .withIndex('by_token', (q) => q.eq('tokenIdentifier', identity.tokenIdentifier))
      .unique();
    if (!post || !user || post.authorId !== user._id) throw new Error('Forbidden');
    await ctx.db.patch(id, { title });
  },
});

Notice that the author is not an argument. Many tutorials pass authorId from the client. But every exported query and mutation is a public endpoint that anyone with your deployment URL can call with any arguments. The server has to derive “who is calling” from ctx.auth, and check ownership before every write. Argument validators (args: {...}) protect types; they do not protect authorization.

A failure mode I have seen more than once is putting a helper that should only run from the backend, such as “grant admin” or “mark invoice paid”, in a plain mutation because it was only called from an action. It is still callable from any client. Use internalMutation, internalQuery and internalAction for those. Internal functions are only reachable through internal.* references from other server functions.

All reads and writes within one mutation are atomic. Either everything commits or nothing does, and a thrown error rolls back the whole mutation. That makes “check then write” patterns (does the username exist? if not, insert it) safe inside a single mutation, which they are not across two separate API calls.

React integration

// app/ConvexClientProvider.tsx
'use client';
import { ConvexProvider, ConvexReactClient } from 'convex/react';
import type { ReactNode } from 'react';

const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!);

export default function ConvexClientProvider({ children }: { children: ReactNode }) {
  return <ConvexProvider client={convex}>{children}</ConvexProvider>;
}
'use client';
import { useMutation, useQuery } from 'convex/react';
import { api } from '../convex/_generated/api';

export function Posts() {
  const posts = useQuery(api.posts.listPublished);
  const create = useMutation(api.posts.create);

  if (posts === undefined) return <p>Loading...</p>;

  return (
    <>
      <button onClick={() => create({ title: 'Hello', content: '...' })}>New post</button>
      <ul>
        {posts.map((p) => (
          <li key={p._id}>{p.title}</li>
        ))}
      </ul>
    </>
  );
}

useQuery returns undefined while loading, so check for it explicitly instead of treating it like an empty array. When a query’s arguments are not ready yet, for example an id from the URL that is still undefined, pass "skip" instead of the arguments: useQuery(api.posts.byAuthor, authorId ? { authorId } : "skip"). Otherwise the query runs with invalid arguments and throws a validation error.

When you use an auth provider such as Clerk, replace ConvexProvider with the provider-specific wrapper (for example ConvexProviderWithClerk), so the client sends the user’s token and ctx.auth.getUserIdentity() returns a value on the server.

Actions for external APIs

// convex/emails.ts
import { internalAction } from './_generated/server';
import { v } from 'convex/values';

export const sendWelcome = internalAction({
  args: { to: v.string(), name: v.string() },
  handler: async (_ctx, { to, name }) => {
    const res = await fetch('https://api.sendgrid.com/v3/mail/send', {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${process.env.SENDGRID_API_KEY}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        personalizations: [{ to: [{ email: to }] }],
        from: { email: '[email protected]' },
        subject: 'Welcome',
        content: [{ type: 'text/plain', value: `Hi ${name}!` }],
      }),
    });
    if (!res.ok) throw new Error(`SendGrid returned ${res.status}`);
  },
});

Environment variables such as SENDGRID_API_KEY are set per deployment with npx convex env set SENDGRID_API_KEY ... or in the dashboard. They are not read from your local .env.local.

The safest way to trigger this from a user action is to schedule it from the mutation that makes the change:

import { internal } from './_generated/api';

export const signUp = mutation({
  args: { name: v.string(), email: v.string() },
  handler: async (ctx, args) => {
    // ...insert the user...
    await ctx.scheduler.runAfter(0, internal.emails.sendWelcome, { to: args.email, name: args.name });
  },
});

Scheduling is part of the transaction. If the mutation throws or is rolled back, the email is never scheduled; if it commits, the action runs right after. Compare this with a client that first calls the mutation and then calls the action itself. If the browser tab closes between the two calls, you get a user with no welcome email. Actions themselves are not transactional and are not automatically retried, because Convex cannot know whether calling an external API twice is safe. If an action calls ctx.runMutation several times, each call commits separately, so design each one to be correct on its own.

File storage

// convex/files.ts
import { mutation } from './_generated/server';
import { v } from 'convex/values';

export const generateUploadUrl = mutation({
  args: {},
  handler: async (ctx) => {
    if (!(await ctx.auth.getUserIdentity())) throw new Error('Not authenticated');
    return await ctx.storage.generateUploadUrl();
  },
});

export const saveFile = mutation({
  args: { storageId: v.id('_storage') },
  handler: async (ctx, { storageId }) => {
    const identity = await ctx.auth.getUserIdentity();
    if (!identity) throw new Error('Not authenticated');
    const user = await ctx.db
      .query('users')
      .withIndex('by_token', (q) => q.eq('tokenIdentifier', identity.tokenIdentifier))
      .unique();
    if (!user) throw new Error('User not found');
    await ctx.db.insert('files', { storageId, ownerId: user._id });
  },
});
// client
const generateUploadUrl = useMutation(api.files.generateUploadUrl);
const saveFile = useMutation(api.files.saveFile);

async function upload(file: File) {
  const url = await generateUploadUrl();
  const res = await fetch(url, {
    method: 'POST',
    headers: { 'Content-Type': file.type },
    body: file,
  });
  const { storageId } = await res.json();
  await saveFile({ storageId });
}

Uploading is a three-step handshake: get a short-lived upload URL, POST the bytes directly to storage, then record the returned storageId in your own table. The file bytes never pass through a mutation. Use v.id('_storage') for the argument so an arbitrary string is rejected. Remember that a file uploaded without the third step becomes an orphan. It exists in storage but nothing references it, so a periodic cleanup job is worth adding if uploads can be abandoned. To display a file, get a URL with ctx.storage.getUrl(storageId) in a query.