Design and build Convex components with clear boundaries, isolated state, and app-facing wrappers. Use when creating a new Convex component, extracting reusable backend logic into one, or packaging Convex functionality for reuse across apps.
Create reusable Convex components with clear boundaries and a small app-facing API.
convex/convex.config.ts, schema.ts, and function files../_generated/server imports, not the app's generated files.app.use(...). If the app does not already have convex/convex.config.ts, create it.components.<name> using ctx.runQuery, ctx.runMutation, or ctx.runAction.npx convex dev and fix codegen, type, or boundary issues before finishing.Ask the user, then pick one path:
| Goal | Shape | Reference |
|---|---|---|
| Component for this app only | Local | references/local-components.md |
| Publish or share across apps | Packaged | references/packaged-components.md |
| User explicitly needs local + shared library code | Hybrid | references/hybrid-components.md |
| Not sure | Default to local | references/local-components.md |
Read exactly one reference file before proceeding.
Unless the user explicitly wants an npm package, default to a local component:
convex/components/<componentName>/defineComponent(...) in its own convex.config.tsconvex/convex.config.ts with app.use(...)npx convex dev generate the component's own _generated/ filesA minimal local component with a table and two functions, plus the app wiring.
// 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.string(),
message: v.string(),
read: v.boolean(),
}).index('by_user', ['userId']),
})
// convex/components/notifications/lib.ts
import { v } from 'convex/values'
import { mutation, query } from './_generated/server.js'
export const send = mutation({
args: { userId: v.string(), message: v.string() },
returns: v.id('notifications'),
handler: async (ctx, args) => {
return await ctx.db.insert('notifications', {
userId: args.userId,
message: args.message,
read: false,
})
},
})
export const listUnread = query({
args: { userId: v.string() },
returns: v.array(
v.object({
_id: v.id('notifications'),
_creationTime: v.number(),
userId: v.string(),
message: v.string(),
read: v.boolean(),
}),
),
handler: async (ctx, args) => {
return await ctx.db
.query('notifications')
.withIndex('by_user', (q) => q.eq('userId', args.userId))
.filter((q) => q.eq(q.field('read'), false))
.collect()
},
})
// convex/convex.config.ts
import { defineApp } from 'convex/server'
import notifications from './components/notifications/convex.config.js'
const app = defineApp()
app.use(notifications)
export default app
// convex/notifications.ts (app-side wrapper)
import { v } from 'convex/values'
import { mutation, query } from './_generated/server'
import { components } from './_generated/api'
import { getAuthUserId } from '@convex-dev/auth/server'
export const sendNotification = mutation({
args: { message: v.string() },
returns: v.null(),
handler: async (ctx, args) => {
const userId = await getAuthUserId(ctx)
if (!userId) throw new Error('Not authenticated')
await ctx.runMutation(components.notifications.lib.send, {
userId,
message: args.message,
})
return null
},
})
export const myUnread = query({
args: {},
handler: async (ctx) => {
const userId = await getAuthUserId(ctx)
if (!userId) throw new Error('Not authenticated')
return await ctx.runQuery(components.notifications.lib.listUnread, {
userId,
})
},
})
Note the reference path shape: a function in convex/components/notifications/lib.ts is called as components.notifications.lib.send from the app.
ctx.auth is not available inside components.process.env.Id types become plain strings in the app-facing ComponentApi.v.id("parentTable") for app-owned tables inside component args or schema.query, mutation, and action from the component's own ./_generated/server.convex/http.ts.paginator from convex-helpers instead of built-in .paginate().args and returns validators to all public component functions.// Bad: component code cannot rely on app auth or env
const identity = await ctx.auth.getUserIdentity()
const apiKey = process.env.OPENAI_API_KEY
// Good: the app resolves auth and env, then passes explicit values
const userId = await getAuthUserId(ctx)
if (!userId) throw new Error('Not authenticated')
await ctx.runAction(components.translator.translate, {
userId,
apiKey: process.env.OPENAI_API_KEY,
text: args.text,
})
// Bad: assuming a component function is directly callable by clients
export const send = components.notifications.send
// Good: re-export through an app mutation or query
export const sendNotification = mutation({
args: { message: v.string() },
returns: v.null(),
handler: async (ctx, args) => {
const userId = await getAuthUserId(ctx)
if (!userId) throw new Error('Not authenticated')
await ctx.runMutation(components.notifications.lib.send, {
userId,
message: args.message,
})
return null
},
})
// Bad: parent app table IDs are not valid component validators