Skip to main content
Technology & EngineeringDatabase Services307 lines

Convex

Build with Convex as a reactive backend. Use this skill when the project needs

Quick Summary27 lines
You are a backend specialist who integrates Convex into projects. Convex is a
reactive backend platform where database queries automatically update the UI
when data changes — no manual subscriptions, polling, or cache invalidation needed.

## Key Points

- Define your schema first — it drives type generation and validation
- Use indexes for every query pattern — Convex requires indexed queries
- Use queries for reads, mutations for writes, actions for external API calls
- Actions cannot read/write the database directly — call mutations from actions
- Use `ctx.auth.getUserIdentity()` in mutations for authorization
- Keep mutations fast — they run in transactions and block other writes
- Use `v.optional()` for fields that may not exist on older documents
- Calling external APIs from queries or mutations — use actions instead
- Not defining indexes for query patterns — queries will fail
- Using `Date.now()` in queries — queries must be deterministic
- Storing large blobs in the database — use file storage
- Not handling the `undefined` state from `useQuery` (data still loading)

## Quick Example

```bash
npm install convex
npx convex dev  # Starts dev server and syncs functions
```
skilldb get database-services-skills/ConvexFull skill: 307 lines
Paste into your CLAUDE.md or agent config

Convex Integration

You are a backend specialist who integrates Convex into projects. Convex is a reactive backend platform where database queries automatically update the UI when data changes — no manual subscriptions, polling, or cache invalidation needed.

Core Philosophy

Reactivity is built in

When a Convex query's data changes, every client subscribed to that query gets updated automatically. You don't write subscription code, invalidate caches, or poll for changes. The reactivity is in the platform, not your code.

Functions are the API

Your backend is TypeScript functions — queries (read), mutations (write), and actions (side effects). No REST endpoints to design, no GraphQL schemas to maintain. Functions are your API surface.

Schema is enforced at runtime

Convex validates data against your schema on every write. TypeScript types are generated from the schema, giving you end-to-end type safety from database to UI.

Setup

Install

npm install convex
npx convex dev  # Starts dev server and syncs functions

Initialize (React)

// convex/_generated/api.ts is auto-generated
import { ConvexProvider, ConvexReactClient } from 'convex/react';

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

function App() {
  return (
    <ConvexProvider client={convex}>
      <MyApp />
    </ConvexProvider>
  );
}

Key Techniques

Schema definition

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

export default defineSchema({
  posts: defineTable({
    title: v.string(),
    content: v.string(),
    authorId: v.id('users'),
    status: v.union(v.literal('draft'), v.literal('published')),
    tags: v.array(v.string()),
    createdAt: v.number(),
  })
    .index('by_author', ['authorId'])
    .index('by_status', ['status', 'createdAt'])
    .searchIndex('search_posts', {
      searchField: 'title',
      filterFields: ['status'],
    }),

  users: defineTable({
    name: v.string(),
    email: v.string(),
    plan: v.union(v.literal('free'), v.literal('pro'), v.literal('enterprise')),
    tokenIdentifier: v.string(),
  }).index('by_token', ['tokenIdentifier']),
});

Queries (reactive reads)

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

export const list = query({
  args: { status: v.optional(v.string()) },
  handler: async (ctx, args) => {
    if (args.status) {
      return ctx.db
        .query('posts')
        .withIndex('by_status', (q) => q.eq('status', args.status))
        .order('desc')
        .take(20);
    }
    return ctx.db.query('posts').order('desc').take(20);
  },
});

export const get = query({
  args: { id: v.id('posts') },
  handler: async (ctx, args) => {
    return ctx.db.get(args.id);
  },
});

Mutations (writes)

// convex/posts.ts
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 ctx.db.insert('posts', {
      title: args.title,
      content: args.content,
      authorId: user._id,
      status: 'draft',
      tags: [],
      createdAt: Date.now(),
    });
  },
});

export const update = mutation({
  args: {
    id: v.id('posts'),
    title: v.optional(v.string()),
    content: v.optional(v.string()),
    status: v.optional(v.union(v.literal('draft'), v.literal('published'))),
  },
  handler: async (ctx, args) => {
    const { id, ...fields } = args;
    await ctx.db.patch(id, fields);
  },
});

export const remove = mutation({
  args: { id: v.id('posts') },
  handler: async (ctx, args) => {
    await ctx.db.delete(args.id);
  },
});

Actions (side effects)

// convex/actions.ts
import { action } from './_generated/server';
import { v } from 'convex/values';
import { api } from './_generated/api';

export const sendEmail = action({
  args: { to: v.string(), subject: v.string(), body: v.string() },
  handler: async (ctx, args) => {
    // Actions can call external APIs
    const res = await fetch('https://api.resend.com/emails', {
      method: 'POST',
      headers: {
        'Authorization': `Bearer ${process.env.RESEND_API_KEY}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        from: 'noreply@yourdomain.com',
        to: args.to,
        subject: args.subject,
        html: args.body,
      }),
    });

    // Actions can call mutations to write data
    await ctx.runMutation(api.emails.logSend, {
      to: args.to,
      subject: args.subject,
      sentAt: Date.now(),
    });
  },
});

Using in React (automatic reactivity)

import { useQuery, useMutation } from 'convex/react';
import { api } from '../convex/_generated/api';

function PostList() {
  // This automatically updates when posts change — no manual refresh
  const posts = useQuery(api.posts.list, { status: 'published' });

  const createPost = useMutation(api.posts.create);

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

  return (
    <div>
      {posts.map(post => <PostCard key={post._id} post={post} />)}
      <button onClick={() => createPost({ title: 'New', content: '...' })}>
        Create Post
      </button>
    </div>
  );
}

Scheduled functions (cron)

// convex/crons.ts
import { cronJobs } from 'convex/server';
import { api } from './_generated/api';

const crons = cronJobs();

crons.interval('cleanup drafts', { hours: 24 }, api.posts.cleanupOldDrafts);
crons.cron('weekly digest', '0 9 * * 1', api.emails.sendWeeklyDigest);

export default crons;

File storage

// Generate upload URL (mutation)
export const generateUploadUrl = mutation(async (ctx) => {
  return ctx.storage.generateUploadUrl();
});

// Store file reference (mutation)
export const saveFile = mutation({
  args: { storageId: v.id('_storage'), name: v.string() },
  handler: async (ctx, args) => {
    await ctx.db.insert('files', {
      storageId: args.storageId,
      name: args.name,
      uploadedAt: Date.now(),
    });
  },
});

// Get file URL (query)
export const getFileUrl = query({
  args: { storageId: v.id('_storage') },
  handler: async (ctx, args) => {
    return ctx.storage.getUrl(args.storageId);
  },
});

Full-text search

export const searchPosts = query({
  args: { query: v.string() },
  handler: async (ctx, args) => {
    return ctx.db
      .query('posts')
      .withSearchIndex('search_posts', (q) =>
        q.search('title', args.query).eq('status', 'published')
      )
      .take(10);
  },
});

Best Practices

  • Define your schema first — it drives type generation and validation
  • Use indexes for every query pattern — Convex requires indexed queries
  • Use queries for reads, mutations for writes, actions for external API calls
  • Actions cannot read/write the database directly — call mutations from actions
  • Use ctx.auth.getUserIdentity() in mutations for authorization
  • Keep mutations fast — they run in transactions and block other writes
  • Use v.optional() for fields that may not exist on older documents

Anti-Patterns

  • Calling external APIs from queries or mutations — use actions instead
  • Not defining indexes for query patterns — queries will fail
  • Using Date.now() in queries — queries must be deterministic
  • Storing large blobs in the database — use file storage
  • Not handling the undefined state from useQuery (data still loading)
  • Making mutations do heavy computation — they hold a write lock
  • Skipping schema definition — lose type safety and runtime validation

Install this skill directly: skilldb add database-services-skills

Get CLI access →