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
1. Install a Component
bash
1# Official components from npm
2npm install @convex-dev/ratelimiter
typescript
1import { defineApp } from "convex/server";
2import ratelimiter from "@convex-dev/ratelimiter/convex.config";
3
4export default defineApp({
5 components: {
6 ratelimiter,
7 },
8});
3. Use in Your Code
typescript
1import { components } from "./_generated/api";
2
3export const createPost = mutation({
4 handler: async (ctx, args) => {
5 // Use the component
6 await components.ratelimiter.check(ctx, {
7 key: `user:${ctx.user._id}`,
8 limit: 10,
9 period: 60000, // 10 requests per minute
10 });
11
12 return await ctx.db.insert("posts", args);
13 },
14});
Sibling Components Pattern
Multiple components working together at the same level:
typescript
1// convex.config.ts
2export default defineApp({
3 components: {
4 // Sibling components - each handles one concern
5 auth: authComponent,
6 storage: storageComponent,
7 payments: paymentsComponent,
8 emails: emailComponent,
9 analytics: analyticsComponent,
10 },
11});
Example: Complete Feature Using Siblings
typescript
1// convex/subscriptions.ts
2import { components } from "./_generated/api";
3
4export const subscribe = mutation({
5 args: { plan: v.string() },
6 handler: async (ctx, args) => {
7 // 1. Verify authentication (auth component)
8 const user = await components.auth.getCurrentUser(ctx);
9
10 // 2. Create payment (payments component)
11 const subscription = await components.payments.createSubscription(ctx, {
12 userId: user._id,
13 plan: args.plan,
14 amount: getPlanAmount(args.plan),
15 });
16
17 // 3. Track conversion (analytics component)
18 await components.analytics.track(ctx, {
19 event: "subscription_created",
20 userId: user._id,
21 plan: args.plan,
22 });
23
24 // 4. Send confirmation (emails component)
25 await components.emails.send(ctx, {
26 to: user.email,
27 template: "subscription_welcome",
28 data: { plan: args.plan },
29 });
30
31 // 5. Store subscription in main app
32 await ctx.db.insert("subscriptions", {
33 userId: user._id,
34 paymentId: subscription.id,
35 plan: args.plan,
36 status: "active",
37 });
38
39 return subscription;
40 },
41});
What this achieves:
- ✅ Each component is single-purpose
- ✅ Components are reusable across features
- ✅ Easy to swap implementations (change email provider)
- ✅ Can update components independently
- ✅ Clear separation of concerns
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
bash
1mkdir -p convex/components/notifications
typescript
1// convex/components/notifications/convex.config.ts
2import { defineComponent } from "convex/server";
3
4export default defineComponent("notifications");
typescript
1// convex/components/notifications/schema.ts
2import { defineSchema, defineTable } from "convex/server";
3import { v } from "convex/values";
4
5export default defineSchema({
6 notifications: defineTable({
7 userId: v.id("users"),
8 message: v.string(),
9 read: v.boolean(),
10 createdAt: v.number(),
11 })
12 .index("by_user", ["userId"])
13 .index("by_user_and_read", ["userId", "read"]),
14});
typescript
1// convex/components/notifications/send.ts
2import { mutation } from "./_generated/server";
3import { v } from "convex/values";
4
5export const send = mutation({
6 args: {
7 userId: v.id("users"),
8 message: v.string(),
9 },
10 handler: async (ctx, args) => {
11 await ctx.db.insert("notifications", {
12 userId: args.userId,
13 message: args.message,
14 read: false,
15 createdAt: Date.now(),
16 });
17 },
18});
19
20export const markRead = mutation({
21 args: { notificationId: v.id("notifications") },
22 handler: async (ctx, args) => {
23 await ctx.db.patch(args.notificationId, { read: true });
24 },
25});
typescript
1// convex/components/notifications/read.ts
2import { query } from "./_generated/server";
3import { v } from "convex/values";
4
5export const list = query({
6 args: { userId: v.id("users") },
7 handler: async (ctx, args) => {
8 return await ctx.db
9 .query("notifications")
10 .withIndex("by_user", q => q.eq("userId", args.userId))
11 .order("desc")
12 .collect();
13 },
14});
15
16export const unreadCount = query({
17 args: { userId: v.id("users") },
18 handler: async (ctx, args) => {
19 const unread = await ctx.db
20 .query("notifications")
21 .withIndex("by_user_and_read", q =>
22 q.eq("userId", args.userId).eq("read", false)
23 )
24 .collect();
25
26 return unread.length;
27 },
28});
Use Your Component
typescript
1// convex.config.ts
2import { defineApp } from "convex/server";
3import notifications from "./components/notifications/convex.config";
4
5export default defineApp({
6 components: {
7 notifications, // Your local component
8 },
9});
typescript
1// convex/tasks.ts - main app code
2import { components } from "./_generated/api";
3
4export const completeTask = mutation({
5 args: { taskId: v.id("tasks") },
6 handler: async (ctx, args) => {
7 const task = await ctx.db.get(args.taskId);
8
9 await ctx.db.patch(args.taskId, { completed: true });
10
11 // Use your component
12 await components.notifications.send(ctx, {
13 userId: task.userId,
14 message: `Task "${task.title}" completed!`,
15 });
16 },
17});
Component Communication Patterns
✅ Parent → Component (Good)
typescript
1// Main app calls component
2await components.storage.upload(ctx, file);
3await components.analytics.track(ctx, event);
✅ Parent → Multiple Siblings (Good)
typescript
1// Main app orchestrates multiple components
2await components.auth.verify(ctx);
3const file = await components.storage.upload(ctx, data);
4await components.notifications.send(ctx, message);
✅ Component Receives Parent Data (Good)
typescript
1// Pass IDs from parent's tables to component
2await components.audit.log(ctx, {
3 userId: user._id, // From parent's users table
4 action: "delete",
5 resourceId: task._id, // From parent's tasks table
6});
7
8// Component stores these as strings/IDs
9// but doesn't access parent tables directly
❌ Component → Parent Tables (Bad)
typescript
1// Inside component code - DON'T DO THIS
2const user = await ctx.db.get(userId); // Error! Can't access parent tables
❌ Sibling → Sibling (Bad)
Components can't call each other directly. If you need this, they should be in the main app or refactor the design.
Real-World Examples
Multi-Tenant SaaS
typescript
1// convex.config.ts
2export default defineApp({
3 components: {
4 auth: "@convex-dev/better-auth",
5 organizations: "./components/organizations",
6 billing: "./components/billing",
7 storage: "@convex-dev/r2",
8 analytics: "./components/analytics",
9 emails: "./components/emails",
10 },
11});
Each component:
auth - User authentication & sessions
organizations - Tenant isolation & permissions
billing - Stripe integration & subscriptions
storage - File uploads to R2
analytics - Event tracking & metrics
emails - Email sending via SendGrid
typescript
1export default defineApp({
2 components: {
3 cart: "./components/cart",
4 inventory: "./components/inventory",
5 orders: "./components/orders",
6 payments: "@convex-dev/polar",
7 shipping: "./components/shipping",
8 recommendations: "./components/recommendations",
9 },
10});
AI Application
typescript
1export default defineApp({
2 components: {
3 agent: "@convex-dev/agent",
4 embeddings: "./components/embeddings",
5 documents: "./components/documents",
6 chat: "./components/chat",
7 workflow: "@convex-dev/workflow",
8 },
9});
Migration from Monolithic
Step 1: Identify Features
Current monolith:
- File uploads (mixed with main app)
- Rate limiting (scattered everywhere)
- Analytics (embedded in functions)
Step 2: Extract One Feature
bash
1# Create component
2mkdir -p convex/components/storage
3
4# Move storage code to component
5# Update imports in main app
Step 3: Test Independently
bash
1# Component has its own tests
2# No coupling to main app
Step 4: Repeat
Extract other features incrementally.
Best Practices
1. Single Responsibility
Each component does ONE thing well:
- ✅ storage component handles files
- ✅ auth component handles authentication
- ❌ Don't create "utils" component with everything
2. Clear API Surface
typescript
1// Export only what's needed
2export { upload, download, delete } from "./storage";
3
4// Keep internals private
5// (Don't export helper functions)
3. Minimal Coupling
typescript
1// ✅ Good: Pass data as arguments
2await components.audit.log(ctx, {
3 userId: user._id,
4 action: "delete"
5});
6
7// ❌ Bad: Component accesses parent tables
8// (Not even possible, but shows the principle)
4. Version Your Components
json
1{
2 "name": "@yourteam/notifications-component",
3 "version": "1.0.0"
4}
5. Document Your Components
Include README with:
- What the component does
- How to install
- How to use
- API reference
- Examples
Troubleshooting
Component not found
bash
1# Make sure component is in convex.config.ts
2# Run: npx convex dev
Can't access parent tables
This is by design! Components are sandboxed.
Pass data as arguments instead.
Component conflicts
Each component has isolated tables.
Components can't see each other's data.
Learn More
Checklist
Remember: Components are about encapsulation and reusability. When in doubt, prefer components over monolithic code!