Convex Components Guide
Use components to encapsulate features and build maintainable, reusable backends.
What Are Convex Components?
Components are self-contained mini-backends that bundle:
-
Their own database schema
-
Their own functions (queries, mutations, actions)
-
Their own data (isolated tables)
-
Clear API boundaries
Think of them as: npm packages for your backend, or microservices without the deployment complexity.
Why Use Components?
Traditional Approach (Monolithic)
convex/ users.ts (500 lines) files.ts (600 lines - upload, storage, permissions, rate limiting) payments.ts (400 lines - Stripe, webhooks, billing) notifications.ts (300 lines) analytics.ts (200 lines)
Total: One big codebase, everything mixed together
Component Approach (Encapsulated)
convex/ components/ storage/ (File uploads - reusable) billing/ (Payments - reusable) notifications/ (Alerts - reusable) analytics/ (Tracking - reusable) convex.config.ts (Wire components together) domain/ (Your actual business logic) users.ts (50 lines - uses components) projects.ts (75 lines - uses components)
Total: Clean, focused, reusable
Quick Start
- Install a Component
Official components from npm
npm install @convex-dev/ratelimiter
- Configure in convex.config.ts
import { defineApp } from "convex/server"; import ratelimiter from "@convex-dev/ratelimiter/convex.config";
export default defineApp({ components: { ratelimiter, }, });
- Use in Your Code
import { components } from "./_generated/api";
export const createPost = mutation({
handler: async (ctx, args) => {
// Use the component
await components.ratelimiter.check(ctx, {
key: user:${ctx.user._id},
limit: 10,
period: 60000, // 10 requests per minute
});
return await ctx.db.insert("posts", args);
}, });
Sibling Components Pattern
Multiple components working together at the same level:
// convex.config.ts export default defineApp({ components: { // Sibling components - each handles one concern auth: authComponent, storage: storageComponent, payments: paymentsComponent, emails: emailComponent, analytics: analyticsComponent, }, });
Example: Complete Feature Using Siblings
// convex/subscriptions.ts import { components } from "./_generated/api";
export const subscribe = mutation({ args: { plan: v.string() }, handler: async (ctx, args) => { // 1. Verify authentication (auth component) const user = await components.auth.getCurrentUser(ctx);
// 2. Create payment (payments component)
const subscription = await components.payments.createSubscription(ctx, {
userId: user._id,
plan: args.plan,
amount: getPlanAmount(args.plan),
});
// 3. Track conversion (analytics component)
await components.analytics.track(ctx, {
event: "subscription_created",
userId: user._id,
plan: args.plan,
});
// 4. Send confirmation (emails component)
await components.emails.send(ctx, {
to: user.email,
template: "subscription_welcome",
data: { plan: args.plan },
});
// 5. Store subscription in main app
await ctx.db.insert("subscriptions", {
userId: user._id,
paymentId: subscription.id,
plan: args.plan,
status: "active",
});
return subscription;
}, });
Official Components
Browse Component Directory:
Authentication
- @convex-dev/better-auth - Better Auth integration
Storage
-
@convex-dev/r2 - Cloudflare R2 file storage
-
@convex-dev/storage - File upload/download
Payments
- @convex-dev/polar - Polar billing & subscriptions
AI
-
@convex-dev/agent - AI agent workflows
-
@convex-dev/embeddings - Vector storage & search
Backend Utilities
-
@convex-dev/ratelimiter - Rate limiting
-
@convex-dev/aggregate - Data aggregations
-
@convex-dev/action-cache - Cache action results
-
@convex-dev/sharded-counter - Distributed counters
-
@convex-dev/migrations - Schema migrations
-
@convex-dev/workflow - Workflow orchestration
Creating Your Own Component
When to Create a Component
Good reasons:
-
Feature is self-contained
-
You'll reuse it across projects
-
Want to share with team/community
-
Complex feature with its own data model
-
Third-party integration wrapper
Not good reasons:
-
One-off business logic
-
Tightly coupled to main app
-
Simple utility functions
Structure
mkdir -p convex/components/notifications
// convex/components/notifications/convex.config.ts import { defineComponent } from "convex/server";
export default defineComponent("notifications");
// convex/components/notifications/schema.ts import { defineSchema, defineTable } from "convex/server"; import { v } from "convex/values";
export default defineSchema({ notifications: defineTable({ userId: v.id("users"), message: v.string(), read: v.boolean(), createdAt: v.number(), }) .index("by_user", ["userId"]) .index("by_user_and_read", ["userId", "read"]), });
// convex/components/notifications/send.ts import { mutation } from "./_generated/server"; import { v } from "convex/values";
export const send = mutation({ args: { userId: v.id("users"), message: v.string(), }, handler: async (ctx, args) => { await ctx.db.insert("notifications", { userId: args.userId, message: args.message, read: false, createdAt: Date.now(), }); }, });
export const markRead = mutation({ args: { notificationId: v.id("notifications") }, handler: async (ctx, args) => { await ctx.db.patch(args.notificationId, { read: true }); }, });
// convex/components/notifications/read.ts import { query } from "./_generated/server"; import { v } from "convex/values";
export const list = query({ args: { userId: v.id("users") }, handler: async (ctx, args) => { return await ctx.db .query("notifications") .withIndex("by_user", q => q.eq("userId", args.userId)) .order("desc") .collect(); }, });
export const unreadCount = query({ args: { userId: v.id("users") }, handler: async (ctx, args) => { const unread = await ctx.db .query("notifications") .withIndex("by_user_and_read", q => q.eq("userId", args.userId).eq("read", false) ) .collect();
return unread.length;
}, });
Component Communication Patterns
Parent to Component (Good)
// Main app calls component await components.storage.upload(ctx, file); await components.analytics.track(ctx, event);
Parent to Multiple Siblings (Good)
// Main app orchestrates multiple components await components.auth.verify(ctx); const file = await components.storage.upload(ctx, data); await components.notifications.send(ctx, message);
Component Receives Parent Data (Good)
// Pass IDs from parent's tables to component await components.audit.log(ctx, { userId: user._id, // From parent's users table action: "delete", resourceId: task._id, // From parent's tasks table });
// Component stores these as strings/IDs // but doesn't access parent tables directly
Component to Parent Tables (Bad)
// Inside component code - DON'T DO THIS const user = await ctx.db.get(userId); // Error! Can't access parent tables
Sibling to Sibling (Bad)
Components can't call each other directly. If you need this, they should be in the main app or refactor the design.
Best Practices
- Single Responsibility
Each component does ONE thing well:
-
Storage component handles files
-
Auth component handles authentication
-
Don't create "utils" component with everything
- Clear API Surface
// Export only what's needed export { upload, download, delete } from "./storage";
// Keep internals private // (Don't export helper functions)
- Minimal Coupling
// Good: Pass data as arguments await components.audit.log(ctx, { userId: user._id, action: "delete" });
// Bad: Component accesses parent tables // (Not even possible, but shows the principle)
- Version Your Components
{ "name": "@yourteam/notifications-component", "version": "1.0.0" }
- Document Your Components
Include README with:
-
What the component does
-
How to install
-
How to use
-
API reference
-
Examples
Checklist
-
Browse Component Directory for existing solutions
-
Install components via npm: npm install @convex-dev/component-name
-
Configure in convex.config.ts
-
Use sibling components for feature encapsulation
-
Create your own components for reusable features
-
Keep components focused (single responsibility)
-
Test components in isolation
-
Document component APIs
-
Version your components properly