Loading...
Loading...
Convex reactive backend expert: schema design, TypeScript functions, real-time subscriptions, auth, file storage, scheduling, and deployment.
npx skill4agent add sickn33/antigravity-awesome-skills convex| Type | Purpose | Can Read DB | Can Write DB | Can Call External APIs | Cached/Reactive |
|---|---|---|---|---|---|
| Query | Read data | ✅ | ❌ | ❌ | ✅ |
| Mutation | Write data | ✅ | ✅ | ❌ | ❌ |
| Action | Side effects | via | via | ✅ | ❌ |
| HTTP Action | Webhooks/custom endpoints | via | via | ✅ | ❌ |
npx create-next-app@latest my-app
cd my-app && npm install convex
npx convex devnpm install convex
npx convex devnpx convex devconvex/.env.localCONVEX_DEPLOYMENTNEXT_PUBLIC_CONVEX_URLmy-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)convex/schema.tsimport { 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 | TypeScript Type | Notes |
|---|---|---|
| | |
| | IEEE 754 float |
| | |
| | |
| | |
| | Document reference |
| | |
| | Nested objects |
| | |
| | |
| | Literal types |
| | Binary data |
| | Explicit 64-bit float (used in vector indexes) |
| | Escape hatch |
// Single-field index
defineTable({ email: v.string() }).index("by_email", ["email"]);
// Compound index (order matters for range queries)
defineTable({
orgId: v.string(),
createdAt: v.number(),
}).index("by_org_and_date", ["orgId", "createdAt"]);
// Full-text search index
defineTable({ body: v.string(), channelId: v.id("channels") }).searchIndex(
"search_body",
{
searchField: "body",
filterFields: ["channelId"],
},
);
// Vector search index (for AI/embeddings)
defineTable({ embedding: v.array(v.float64()), text: v.string() }).vectorIndex(
"by_embedding",
{
vectorField: "embedding",
dimensions: 1536,
},
);import { query } from "./_generated/server";
import { v } from "convex/values";
// Simple query — list all tasks
export const list = query({
args: {},
handler: async (ctx) => {
return await ctx.db.query("tasks").collect();
},
});
// Query with arguments and filtering
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);
},
});
// Query with auth check
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,
});
},
});ctx.runQueryctx.runMutationimport { 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) => {
// Call external API
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");
// Write result back to database via mutation
await ctx.runMutation(api.emails.recordSent, {
to: args.to,
subject: args.subject,
sentAt: Date.now(),
});
},
});
// Generate AI embeddings
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,
});
},
});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");
// Verify webhook signature here...
const event = JSON.parse(body);
await ctx.runMutation(api.payments.handleWebhook, { event });
return new Response("OK", { status: 200 });
}),
});
export default http;// app/ConvexClientProvider.tsx
"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>;
}// app/layout.tsx — wrap children
import { ConvexClientProvider } from "./ConvexClientProvider";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>
<ConvexClientProvider>{children}</ConvexClientProvider>
</body>
</html>
);
}// Component using Convex hooks
"use client";
import { useQuery, useMutation } from "convex/react";
import { api } from "@/convex/_generated/api";
export function TaskList() {
// Reactive query — auto-updates when data changes
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>
);
}// Component using Paginated Queries
"use client";
import { usePaginatedQuery } from "convex/react";
import { api } from "@/convex/_generated/api";
export function MessageLog() {
const { results, status, loadMore } = usePaginatedQuery(
api.messages.listPaginated,
{}, // args
{ 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>
);
}@convex-dev/auth// app/ConvexClientProvider.tsx
"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>
);
}// Client-side sign in
import { useAuthActions } from "@convex-dev/auth/react";
export function Login() {
const { signIn } = useAuthActions();
return <button onClick={() => signIn("github")}>Sign in with GitHub</button>;
}// app/ConvexClientProvider.tsx
"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>
);
}@convex-dev/better-authnpm 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:3000convex// services/convex.service.ts
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);
// Reactive signal — updates automatically when data changes
tasks = signal<FunctionReturnType<typeof api.tasks.list> | undefined>(
undefined,
);
constructor() {
// Subscribe to a reactive query
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();
}
}// Component usage
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 libraryprovides a more Angular-native experience with React-like hooks adapted for Angular DI and Signals.@robmanganelly/ngx-convex
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,
});
},
});// convex/crons.ts
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", // Every Monday at 9 AM
api.emails.sendWeeklyDigest,
);
export default crons;// Generate an upload URL (mutation)
export const generateUploadUrl = mutation({
args: {},
handler: async (ctx) => {
return await ctx.storage.generateUploadUrl();
},
});
// Save file reference after upload (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,
});
},
});
// Get a URL to serve a file (query)
export const getFileUrl = query({
args: { storageId: v.id("_storage") },
handler: async (ctx, args) => {
return await ctx.storage.getUrl(args.storageId);
},
});# Set environment variables for your deployment
npx convex env set OPENAI_API_KEY sk-...
npx convex env set SENDGRID_API_KEY SG...
# List current env vars
npx convex env list
# Remove an env var
npx convex env unset OPENAI_API_KEY// Only available in actions
const apiKey = process.env.OPENAI_API_KEY;# Development (watches for changes, syncs to dev deployment)
npx convex dev
# Deploy to production
npx convex deploy
# Import data
npx convex import --table tasks data.jsonl
# Export data
npx convex export --path ./backup
# Open Convex dashboard
npx convex dashboard
# Run a function from CLI
npx convex run tasks:list
# View logs
npx convex logsDate.now()Math.random()v.id("tableName")ctx.runQueryctx.runMutationctx.dbnullwithIndex.filter().collect().take(N).paginate().filter().filter().withIndex()_storagerunQueryrunMutationundefinedundefinedDocument not foundprocess.envwithIndex().filter()v.optional()@firebase@supabase-automation@prisma-expert@react-patterns@nextjs-app-router@authentication-oauth@stripe