| name | convex |
| description | Convex reactive backend expert: schema design, TypeScript functions, real-time subscriptions, auth, file storage, scheduling, and deployment. |
| risk | safe |
| source | https://docs.convex.dev |
| date_added | 2026-02-27 |
Convex
You are an expert in Convex — the open-source, reactive backend platform where queries are TypeScript code. You have deep knowledge of schema design, function authoring (queries, mutations, actions), real-time data subscriptions, authentication, file storage, scheduling, and deployment workflows across React, Next.js, Angular, Vue, Svelte, React Native, and server-side environments.
When to Use
- Use when building a new project with Convex as the backend
- Use when adding Convex to an existing React, Next.js, Angular, Vue, Svelte, or React Native app
- Use when designing schemas for a Convex document-relational database
- Use when writing or debugging Convex functions (queries, mutations, actions)
- Use when implementing real-time/reactive data patterns
- Use when setting up authentication with Convex Auth or third-party providers (Clerk, Auth0, etc.)
- Use when working with Convex file storage, scheduled functions, or cron jobs
- Use when deploying or managing Convex projects
Core Concepts
Convex is a document-relational database with a fully managed backend. Key differentiators:
- Reactive by default: Queries automatically re-run and push updates to all connected clients when underlying data changes
- TypeScript-first: All backend logic — queries, mutations, actions, schemas — is written in TypeScript
- ACID transactions: Serializable isolation with optimistic concurrency control
- No infrastructure to manage: Serverless, scales automatically, zero config
- End-to-end type safety: Types flow from schema → backend functions → client hooks
Function Types
| Type | Purpose | Can Read DB | Can Write DB | Can Call External APIs | Cached/Reactive |
|---|
| Query | Read data | ✅ | ❌ | ❌ | ✅ |
| Mutation | Write data | ✅ | ✅ | ❌ | ❌ |
| Action | Side effects | via runQuery | via runMutation | ✅ | ❌ |
| HTTP Action | Webhooks/custom endpoints | via runQuery | via runMutation | ✅ | ❌ |
Project Setup
New Project (Next.js)
npx create-next-app@latest my-app
cd my-app && npm install convex
npx convex dev
Add to Existing Project
npm install convex
npx convex dev
The npx convex dev command:
- Prompts you to log in (GitHub)
- Creates a project and deployment
- Generates
convex/ folder for backend functions
- Syncs functions to your dev deployment in real-time
- Creates
.env.local with CONVEX_DEPLOYMENT and NEXT_PUBLIC_CONVEX_URL
Folder Structure
my-app/
├── convex/
│ ├── _generated/ ← Auto-generated (DO NOT EDIT)
│ │ ├── api.d.ts
│ │ ├── dataModel.d.ts
│ │ └── server.d.ts
│ ├── schema.ts ← Database schema definition
│ ├── tasks.ts ← Query/mutation functions
│ └── http.ts ← HTTP actions (optional)
├── .env.local ← CONVEX_DEPLOYMENT, NEXT_PUBLIC_CONVEX_URL
└── convex.json ← Project config (optional)
Schema Design
Define your schema in convex/schema.ts using the validator library:
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export default defineSchema({
users: defineTable({
name: v.string(),
email: v.string(),
avatarUrl: v.optional(v.string()),
tokenIdentifier: v.string(),
})
.index("by_token", ["tokenIdentifier"])
.index("by_email", ["email"]),
messages: defineTable({
authorId: v.id("users"),
channelId: v.id("channels"),
body: v.string(),
attachmentId: v.optional(v.id("_storage")),
})
.index("by_channel", ["channelId"])
.searchIndex("search_body", { searchField: "body" }),
channels: defineTable({
name: v.string(),
description: v.optional(v.string()),
isPrivate: v.boolean(),
}),
});
Validator Types
| Validator | TypeScript Type | Notes |
|---|
v.string() | string | |
v.number() | number | IEEE 754 float |
v.bigint() | bigint | |
v.boolean() | boolean | |
v.null() | null | |
v.id("tableName") | Id<"tableName"> | Document reference |
v.array(v.string()) | string[] | |
v.object({...}) | {...} | Nested objects |
v.optional(v.string()) | string | undefined | |
v.union(v.string(), v.number()) | string | number | |
v.literal("active") | "active" | Literal types |
v.bytes() | ArrayBuffer | Binary data |
v.float64() | number | Explicit 64-bit float (used in vector indexes) |
v.any() | any | Escape hatch |
Indexes
defineTable({ email: v.string() }).index("by_email", ["email"]);
defineTable({
orgId: v.string(),
createdAt: v.number(),
}).index("by_org_and_date", ["orgId", "createdAt"]);
defineTable({ body: v.string(), channelId: v.id("channels") }).searchIndex(
"search_body",
{
searchField: "body",
filterFields: ["channelId"],
},
);
defineTable({ embedding: v.array(v.float64()), text: v.string() }).vectorIndex(
"by_embedding",
{
vectorField: "embedding",
dimensions: 1536,
},
);
Writing Functions
Queries (Read Data)
Queries are reactive — clients automatically get updates when data changes.
import { query } from "./_generated/server";
import { v } from "convex/values";
export const list = query({
args: {},
handler: async (ctx) => {
return await ctx.db.query("tasks").collect();
},
});
export const getByChannel = query({
args: { channelId: v.id("channels") },
handler: async (ctx, args) => {
return await ctx.db
.query("messages")
.withIndex("by_channel", (q) => q.eq("channelId", args.channelId))
.order("desc")
.take(50);
},
});
export const getMyProfile = query({
args: {},
handler: async (ctx) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) return null;
return await ctx.db
.query("users")
.withIndex("by_token", (q) =>
q.eq("tokenIdentifier", identity.tokenIdentifier),
)
.unique();
},
});
### Paginated Queries
Use cursor-based pagination for lists or infinite scroll UIs.
```typescript
import { query } from "./_generated/server";
import { paginationOptsValidator } from "convex/server";
export const listPaginated = query({
args: {
paginationOpts: paginationOptsValidator
},
handler: async (ctx, args) => {
return await ctx.db
.query("messages")
.order("desc")
.paginate(args.paginationOpts);
},
});
```
### Mutations (Write Data)
Mutations run as ACID transactions with serializable isolation.
```typescript
import { mutation } from "./_generated/server";
import { v } from "convex/values";
// Insert a document
export const create = mutation({
args: { text: v.string(), isCompleted: v.boolean() },
handler: async (ctx, args) => {
const taskId = await ctx.db.insert("tasks", {
text: args.text,
isCompleted: args.isCompleted,
});
return taskId;
},
});
// Update a document
export const update = mutation({
args: { id: v.id("tasks"), isCompleted: v.boolean() },
handler: async (ctx, args) => {
await ctx.db.patch(args.id, { isCompleted: args.isCompleted });
},
});
// Delete a document
export const remove = mutation({
args: { id: v.id("tasks") },
handler: async (ctx, args) => {
await ctx.db.delete(args.id);
},
});
// Multi-document transaction (automatically atomic)
export const transferCredits = mutation({
args: {
fromUserId: v.id("users"),
toUserId: v.id("users"),
amount: v.number(),
},
handler: async (ctx, args) => {
const fromUser = await ctx.db.get(args.fromUserId);
const toUser = await ctx.db.get(args.toUserId);
if (!fromUser || !toUser) throw new Error("User not found");
if (fromUser.credits < args.amount) throw new Error("Insufficient credits");
await ctx.db.patch(args.fromUserId, {
credits: fromUser.credits - args.amount,
});
await ctx.db.patch(args.toUserId, {
credits: toUser.credits + args.amount,
});
},
});
Actions (External APIs & Side Effects)
Actions can call third-party services but cannot directly access the database — they must use ctx.runQuery and ctx.runMutation.
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) => {
const response = 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: args.to }] }],
from: { email: "noreply@example.com" },
subject: args.subject,
content: [{ type: "text/plain", value: args.body }],
}),
});
if (!response.ok) throw new Error("Failed to send email");
await ctx.runMutation(api.emails.recordSent, {
to: args.to,
subject: args.subject,
sentAt: Date.now(),
});
},
});
export const generateEmbedding = action({
args: { text: v.string(), documentId: v.id("documents") },
handler: async (ctx, args) => {
const response = await fetch("https://api.openai.com/v1/embeddings", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "text-embedding-3-small",
input: args.text,
}),
});
const { data } = await response.json();
await ctx.runMutation(api.documents.saveEmbedding, {
documentId: args.documentId,
embedding: data[0].embedding,
});
},
});
HTTP Actions (Webhooks)
import { httpRouter } from "convex/server";
import { httpAction } from "./_generated/server";
import { api } from "./_generated/api";
const http = httpRouter();
http.route({
path: "/webhooks/stripe",
method: "POST",
handler: httpAction(async (ctx, request) => {
const body = await request.text();
const signature = request.headers.get("stripe-signature");
const event = JSON.parse(body);
await ctx.runMutation(api.payments.handleWebhook, { event });
return new Response("OK", { status: 200 });
}),
});
export default http;
Client-Side Integration
React / Next.js
"use client";
import { ConvexProvider, ConvexReactClient } from "convex/react";
import { ReactNode } from "react";
const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!);
export function ConvexClientProvider({ children }: { children: ReactNode }) {
return <ConvexProvider client={convex}>{children}</ConvexProvider>;
}
import { ConvexClientProvider } from "./ConvexClientProvider";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<ConvexClientProvider>{children}</ConvexClientProvider>
</body>
</html>
);
}
"use client";
import { useQuery, useMutation } from "convex/react";
import { api } from "@/convex/_generated/api";
export function TaskList() {
const tasks = useQuery(api.tasks.list);
const addTask = useMutation(api.tasks.create);
const toggleTask = useMutation(api.tasks.update);
if (tasks === undefined) return <p>Loading...</p>;
return (
<div>
{tasks.map((task) => (
<div key={task._id}>
<input
type="checkbox"
checked={task.isCompleted}
onChange={() =>
toggleTask({ id: task._id, isCompleted: !task.isCompleted })
}
/>
{task.text}
</div>
))}
<button onClick={() => addTask({ text: "New task", isCompleted: false })}>
Add Task
</button>
</div>
);
}
"use client";
import { usePaginatedQuery } from "convex/react";
import { api } from "@/convex/_generated/api";
export function MessageLog() {
const { results, status, loadMore } = usePaginatedQuery(
api.messages.listPaginated,
{},
{ initialNumItems: 20 }
);
return (
<div>
{results.map((msg) => (
<div key={msg._id}>{msg.body}</div>
))}
{status === "LoadingFirstPage" && <p>Loading...</p>}
{status === "CanLoadMore" && (
<button onClick={() => loadMore(20)}>Load More</button>
)}
</div>
);
}
With Auth (First-Party Convex Auth)
Convex provides a robust, native authentication library (@convex-dev/auth) featuring Magic Links, Passwords, and 80+ OAuth providers without needing a third-party service.
"use client";
import { ConvexAuthProvider } from "@convex-dev/auth/react";
import { ConvexReactClient } from "convex/react";
import { ReactNode } from "react";
const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!);
export function ConvexClientProvider({ children }: { children: ReactNode }) {
return (
<ConvexAuthProvider client={convex}>
{children}
</ConvexAuthProvider>
);
}
import { useAuthActions } from "@convex-dev/auth/react";
export function Login() {
const { signIn } = useAuthActions();
return <button onClick={() => signIn("github")}>Sign in with GitHub</button>;
}
With Auth (Third-Party Clerk Example)
If you prefer a hosted third-party solution like Clerk:
"use client";
import { ConvexProviderWithClerk } from "convex/react-clerk";
import { ClerkProvider, useAuth } from "@clerk/nextjs";
import { ConvexReactClient } from "convex/react";
const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!);
export function ConvexClientProvider({ children }: { children: ReactNode }) {
return (
<ClerkProvider publishableKey={process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY!}>
<ConvexProviderWithClerk client={convex} useAuth={useAuth}>
{children}
</ConvexProviderWithClerk>
</ClerkProvider>
);
}
With Auth (Better Auth Component)
Convex also has a community component (@convex-dev/better-auth) that integrates the Better Auth library directly into the Convex backend. This is currently in early alpha.
npm install better-auth @convex-dev/better-auth
npx convex env set BETTER_AUTH_SECRET your-secret-here
npx convex env set SITE_URL http://localhost:3000
Better Auth provides email/password, social logins, two-factor authentication, and session management — all running inside Convex functions rather than an external auth server.
Angular Integration
Convex does not have an official Angular client library, but Angular apps can use the core convex package directly with Angular's Dependency Injection and Signals.
import { Injectable, signal, effect, OnDestroy } from "@angular/core";
import { ConvexClient } from "convex/browser";
import { api } from "../../convex/_generated/api";
import { FunctionReturnType } from "convex/server";
@Injectable({ providedIn: "root" })
export class ConvexService implements OnDestroy {
private client = new ConvexClient(environment.convexUrl);
tasks = signal<FunctionReturnType<typeof api.tasks.list> | undefined>(
undefined,
);
constructor() {
this.client.onUpdate(api.tasks.list, {}, (result) => {
this.tasks.set(result);
});
}
async addTask(text: string) {
await this.client.mutation(api.tasks.create, {
text,
isCompleted: false,
});
}
ngOnDestroy() {
this.client.close();
}
}
import { Component, inject } from "@angular/core";
import { ConvexService } from "./services/convex.service";
@Component({
selector: "app-task-list",
template: `
@if (convex.tasks(); as tasks) {
@for (task of tasks; track task._id) {
<div>{{ task.text }}</div>
}
} @else {
<p>Loading...</p>
}
<button (click)="convex.addTask('New task')">Add Task</button>
`,
})
export class TaskListComponent {
convex = inject(ConvexService);
}
Note: The community library @robmanganelly/ngx-convex provides a more Angular-native experience with React-like hooks adapted for Angular DI and Signals.
Scheduling & Cron Jobs
One-off Scheduled Functions
import { mutation } from "./_generated/server";
import { api } from "./_generated/api";
export const sendReminder = mutation({
args: { userId: v.id("users"), message: v.string(), delayMs: v.number() },
handler: async (ctx, args) => {
await ctx.scheduler.runAfter(args.delayMs, api.notifications.send, {
userId: args.userId,
message: args.message,
});
},
});
Cron Jobs
import { cronJobs } from "convex/server";
import { api } from "./_generated/api";
const crons = cronJobs();
crons.interval("clear old logs", { hours: 24 }, api.logs.clearOld);
crons.cron(
"weekly digest",
"0 9 * * 1",
api.emails.sendWeeklyDigest,
);
export default crons;
File Storage
export const generateUploadUrl = mutation({
args: {},
handler: async (ctx) => {
return await ctx.storage.generateUploadUrl();
},
});
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,
});
},
});
export const getFileUrl = query({
args: { storageId: v.id("_storage") },
handler: async (ctx, args) => {
return await ctx.storage.getUrl(args.storageId);
},
});
Environment Variables
npx convex env set OPENAI_API_KEY sk-...
npx convex env set SENDGRID_API_KEY SG...
npx convex env list
npx convex env unset OPENAI_API_KEY
Access in actions (NOT in queries or mutations):
const apiKey = process.env.OPENAI_API_KEY;
Deployment & CLI
npx convex dev
npx convex deploy
npx convex import --table tasks data.jsonl
npx convex export --path ./backup
npx convex dashboard
npx convex run tasks:list
npx convex logs
Best Practices
- ✅ Define schemas — adds type safety across your entire stack
- ✅ Use indexes for queries — avoids full table scans
- ✅ Use compound indexes with equality filters first, range filter last
- ✅ Rely on native determinism —
Date.now() and Math.random() are 100% safe to use in queries and mutations because Convex freezes time at the start of every function execution!
- ✅ Use
v.id("tableName") for document references instead of plain strings
- ✅ Use actions for external API calls (never call external APIs from queries or mutations)
- ✅ Use
ctx.runQuery / ctx.runMutation from actions — never access ctx.db directly in actions
- ✅ Add argument validators to all functions — they enforce runtime type safety
- ✅ Return
null when a document isn't found instead of throwing an error unless missing is exceptional
- ✅ Prefer
withIndex over .filter() for query performance
Anti-Patterns to Avoid
- ❌ External API calls in queries/mutations: Only actions can call external services. Queries and mutations run in the Convex transaction engine.
- ❌ Doing slow CPU-bound work in mutations: Mutations block database commits; offload heavy processing to actions.
- ❌ Using
.collect() on large tables without limits: Fetches all documents into memory. Use .take(N) or .paginate().
- ❌ Skipping schema definition: Without a schema you lose end-to-end type safety, the main Convex advantage.
- ❌ Using
.filter() instead of indexes: .filter() does a full table scan. Define an index and use .withIndex().
- ❌ Storing large blobs in documents: Use Convex file storage (
_storage) for files; keep documents lean.
- ❌ Circular
runQuery/runMutation chains: Actions calling mutations that schedule actions can create infinite loops.
Common Pitfalls
-
Problem: "Query returns undefined on first render"
Solution: This is expected — Convex queries are async. Check for undefined before rendering (this means loading, not empty).
-
Problem: "Mutation throws Document not found"
Solution: Documents may have been deleted between your read and write due to optimistic concurrency. Re-read inside the mutation.
-
Problem: "process.env is undefined in query/mutation"
Solution: Environment variables are only accessible in actions (not queries or mutations) because queries/mutations run in the deterministic transaction engine.
-
Problem: "Function handler is too slow"
Solution: Add indexes for your query patterns. Use withIndex() instead of .filter(). For complex operations, break into smaller mutations.
-
Problem: "Schema push fails with existing data"
Solution: Convex validates existing data against new schemas. Either migrate existing documents first, or use v.optional() for new fields.
Limitations
- Queries and mutations cannot call external HTTP APIs (use actions instead)
- No raw SQL — you work with the Convex query builder API
- Environment variables only available in actions, not in queries or mutations
- Document size limit of 1MB
- Maximum function execution time limits apply
- No server-side rendering of Convex data without specific SSR patterns (use preloading)
- Schemas are enforced at write-time; changing schemas requires data migration for existing documents
Related Skills
@firebase — Alternative BaaS with Firestore (compare: Convex is TypeScript-first with ACID transactions)
@supabase-automation — Alternative with PostgreSQL backend (compare: Convex is document-relational with built-in reactivity)
@prisma-expert — ORM for traditional databases (Convex replaces both ORM and database)
@react-patterns — Frontend patterns that pair well with Convex React hooks
@nextjs-app-router — Next.js App Router integration patterns
@authentication-oauth — Auth patterns (Convex supports Clerk, Auth0, Convex Auth)
@stripe — Payment integration via Convex actions and HTTP webhooks
Resources