Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Guide to using Convex components for feature encapsulation. Learn about sibling components, creating your own, and when to use components vs monolithic code.
.claude/skills/kunanonj-cursor-plugin-convex-components-guide/SKILL.md| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-03 | ✗→✓ | ▲ Improved | 107% | 0% |
| case-23 | ✗→✓ | ▲ Improved | 162% | 0% |
| case-10 | ✗→✓ | ▲ Improved | 216% | 0% |
| case-12 | ✗→✓ | ▲ Improved | 754% | 0% |
| case-14 | ✗→✓ | ▲ Improved | 156% | 0% |
Use components to encapsulate features and build maintainable, reusable backends.
Components are self-contained mini-backends that bundle:
Think of them as: npm packages for your backend, or microservices without the deployment complexity.
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 togetherconvex/
├── 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, reusablebash# Official components from npm npm install @convex-dev/ratelimiter
typescriptimport { defineApp } from "convex/server"; import ratelimiter from "@convex-dev/ratelimiter/convex.config"; export default defineApp({ components: { ratelimiter, }, });
typescriptimport { 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); }, });
Multiple components working together at the same level:
typescript// convex.config.ts export default defineApp({ components: { // Sibling components - each handles one concern auth: authComponent, storage: storageComponent, payments: paymentsComponent, emails: emailComponent, analytics: analyticsComponent, }, });
typescript// 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; }, });
What this achieves:
Browse Component Directory:
Good reasons:
Not good reasons:
bashmkdir -p convex/components/notifications
typescript// convex/components/notifications/convex.config.ts import { defineComponent } from "convex/server"; export default defineComponent("notifications");
typescript// 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"]), });
typescript// 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 }); }, });
typescript// 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; }, });
typescript// convex.config.ts import { defineApp } from "convex/server"; import notifications from "./components/notifications/convex.config"; export default defineApp({ components: { notifications, // Your local component }, });
typescript// convex/tasks.ts - main app code import { components } from "./_generated/api"; export const completeTask = mutation({ args: { taskId: v.id("tasks") }, handler: async (ctx, args) => { const task = await ctx.db.get(args.taskId); await ctx.db.patch(args.taskId, { completed: true }); // Use your component await components.notifications.send(ctx, { userId: task.userId, message: `Task "${task.title}" completed!`, }); }, });
typescript// Main app calls component await components.storage.upload(ctx, file); await components.analytics.track(ctx, event);
typescript// Main app orchestrates multiple components await components.auth.verify(ctx); const file = await components.storage.upload(ctx, data); await components.notifications.send(ctx, message);
typescript// 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
typescript// Inside component code - DON'T DO THIS const user = await ctx.db.get(userId); // Error! Can't access parent tables
Components can't call each other directly. If you need this, they should be in the main app or refactor the design.
typescript// convex.config.ts export default defineApp({ components: { auth: "@convex-dev/better-auth", organizations: "./components/organizations", billing: "./components/billing", storage: "@convex-dev/r2", analytics: "./components/analytics", emails: "./components/emails", }, });
Each component:
auth - User authentication & sessionsorganizations - Tenant isolation & permissionsbilling - Stripe integration & subscriptionsstorage - File uploads to R2analytics - Event tracking & metricsemails - Email sending via SendGridtypescriptexport default defineApp({ components: { cart: "./components/cart", inventory: "./components/inventory", orders: "./components/orders", payments: "@convex-dev/polar", shipping: "./components/shipping", recommendations: "./components/recommendations", }, });
typescriptexport default defineApp({ components: { agent: "@convex-dev/agent", embeddings: "./components/embeddings", documents: "./components/documents", chat: "./components/chat", workflow: "@convex-dev/workflow", }, });
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# Create component mkdir -p convex/components/storage # Move storage code to component # Update imports in main app
Step 3: Test Independently
bash# Component has its own tests # No coupling to main app
Step 4: Repeat Extract other features incrementally.
Each component does ONE thing well:
typescript// Export only what's needed export { upload, download, delete } from "./storage"; // Keep internals private // (Don't export helper functions)
typescript// ✅ 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)
json{ "name": "@yourteam/notifications-component", "version": "1.0.0" }
Include README with:
bash# Make sure component is in convex.config.ts # Run: npx convex dev
This is by design! Components are sandboxed.
Pass data as arguments instead.Each component has isolated tables.
Components can't see each other's data.npm install @convex-dev/component-nameconvex.config.tsRemember: Components are about encapsulation and reusability. When in doubt, prefer components over monolithic code!
| Case | Status | Duration (ms) | Turns | Tokens | Tool calls | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Without | With | Δ | Without | With | Δ | Without | With | Δ | Without | With | Δ | ||
case-05 | pass→pass | 11,811 | 6,648 | -44% | 1 | 1 | 0% | 2,599 | 5,065 | +95% | 0 | 0 | — |
case-01 | fail→fail | 10,786 | 7,701 | -29% | 1 | 1 | 0% | 2,926 | 5,365 | +83% | 0 | 0 | — |
case-02 | fail→fail | 12,386 | 7,360 | -41% | 1 | 1 | 0% | 3,281 | 5,527 | +68% | 0 | 0 | — |
case-03 | fail→pass | 14,984 | 12,422 | -17% | 1 | 1 | 0% | 3,027 | 6,271 | +107% | 0 | 0 | — |
case-04 | fail→fail | 8,469 | 5,757 | -32% | 1 | 1 | 0% | 2,182 | 4,905 | +125% | 0 | 0 | — |
case-06 | pass→pass | 8,405 | 5,273 | -37% | 1 | 1 | 0% | 1,732 | 4,527 | +161% | 0 | 0 | — |
case-07 | pass→pass | 4,139 | 3,129 | -24% | 1 | 1 | 0% | 839 | 4,085 | +387% | 0 | 0 | — |
case-08 | pass→pass | 5,135 | 2,806 | -45% | 1 | 1 | 0% | 1,070 | 4,048 | +278% | 0 | 0 | — |
case-09 | pass→pass | 4,750 | 2,725 | -43% | 1 | 1 | 0% | 992 | 3,953 | +298% | 0 | 0 | — |
case-23 | fail→pass | 9,529 | 6,521 | -32% | 1 | 1 | 0% | 1,941 | 5,078 | +162% | 0 | 0 | — |
case-10 | fail→pass | 5,465 | 2,108 | -61% | 1 | 1 | 0% | 1,246 | 3,943 | +216% | 0 | 0 | — |
case-11 | pass→pass | 2,679 | 1,413 | -47% | 1 | 1 | 0% | 510 | 3,728 | +631% | 0 | 0 | — |
case-12 | fail→pass | 2,519 | 1,807 | -28% | 1 | 1 | 0% | 448 | 3,826 | +754% | 0 | 0 | — |
case-13 | pass→pass | 10,135 | 4,736 | -53% | 1 | 1 | 0% | 1,972 | 4,515 | +129% | 0 | 0 | — |
case-14 | fail→pass | 14,361 | 12,653 | -12% | 1 | 1 | 0% | 2,318 | 5,943 | +156% | 0 | 0 | — |
case-15 | pass→pass | 2,909 | 1,597 | -45% | 1 | 1 | 0% | 486 | 3,767 | +675% | 0 | 0 | — |
case-16 | fail→pass | 16,115 | 17,681 | +10% | 1 | 1 | 0% | 2,834 | 7,320 | +158% | 0 | 0 | — |
case-17 | pass→pass | 5,863 | 3,273 | -44% | 1 | 1 | 0% | 1,122 | 4,185 | +273% | 0 | 0 | — |
case-18 | pass→pass | 4,274 | 3,657 | -14% | 1 | 1 | 0% | 744 | 4,165 | +460% | 0 | 0 | — |
case-19 | fail→pass | 7,421 | 2,769 | -63% | 1 | 1 | 0% | 1,583 | 4,088 | +158% | 0 | 0 | — |
case-20 | pass→pass | 2,999 | 1,625 | -46% | 1 | 1 | 0% | 538 | 3,706 | +589% | 0 | 0 | — |
case-21 | pass→pass | 6,705 | 5,677 | -15% | 1 | 1 | 0% | 1,660 | 4,838 | +191% | 0 | 0 | — |
case-22 | pass→pass | 9,522 | 6,059 | -36% | 1 | 1 | 0% | 2,225 | 4,968 | +123% | 0 | 0 | — |
DecimalAI ran this skill against gemini-3.6-flash twice over the same eval suite — once with the skill loaded and once without — and compared the two runs case by case. 23 cases were attempted. The headline lift of +30 percentage points is the difference between those two pass rates over the 23 comparable cases.
Without the skill loaded, the model failed this case. With it loaded, the same prompt on the same model passed. This is one improved case from the latest verified run; every case, including any that regressed, is in the table above.
Other measured skills in the registry, with their headline benchmark lift.