Skip to main content

Authoring Components

Building a Convex Component lets you package up Convex functions, schemas, and persistent state into a reusable module that you or other developers can drop into their projects.

They differ from regular libraries in that they have their own database tables, sub-transactions, and can define functions that run in an isolated environment.

Trying to decide between writing a library or a component? Building it as a component allows you to:

  • Persist data to tables where you control the schema.
  • Isolate access to data behind an API boundary.
  • Define queries, mutations, and actions that can run asynchronously to manage complex workflows.
  • Share functionality between apps in a predictable way.

Anatomy of a component

Practically speaking, a component is defined in a folder containing a convex.config.ts. The component's folder has the same structure as a normal convex/ folder:

 component/
├── _generated/ # Generated code for the component's API and data model.
├── convex.config.ts # Defines the component and its child components.
├── schema.ts # Defines a schema only accessible by the component
└-- …folders/files.ts # Queries, mutations, and actions for the component.

The component's convex.config.ts file configures the component's default name and child components.

component/convex.config.ts
import { defineComponent } from "convex/server";
// import workpool from "@convex-dev/workpool/convex.config.js";
// import localComponent from "../localComponent/convex.config.js";
const component = defineComponent("myComponent");
// component.use(workpool);
// component.use(localComponent, { name: "customName" });
export default component;

Instances of the component are configured when used by the main app or other components in their convex.config.ts files, forming a tree of components, with the main app at the root.

Getting started

The source code for components can be a local folder or bundled into an NPM package.

Local components

The easiest way to get started is by creating a new folder for your component and adding a convex.config.ts file to it (like the one above). Then import it in your app's convex/convex.config.ts file:

convex/convex.config.ts
import { defineApp } from "convex/server";
import myComponent from "./components/myComponent/convex.config.js";

const app = defineApp();
app.use(myComponent);
export default app;

Once installed, npx convex dev will generate code in ./components/myComponent/_generated/ and re-generate it whenever you make changes to the component's code.

Tip: The recommended pattern for local components is to organize them in a convex/components folder, but they can be stored anywhere in your project.

Packaged components

Components can be distributed and installed as NPM packages, enabling you to share solutions to common problems with the broader developer community via the Convex Components directory.

Get started with a new project using the component template:

npm create convex@latest -- --component

Follow the CLI's instructions to get started and keep the component's generated code up-to-date. See below for more information on building and publishing NPM package components.

Hybrid components

Hybrid components define a local component, but use shared library code for some of the functionality. This allows you to provide a extra flexibility when users need to override or extend the schema or functions.

An example of a hybrid component is the Better Auth Component.

Note: in general, components should be composed or designed to be extended explicitly, as hybrid components introduce a lot of complexity for maintaining and updating the component in backwards-compatible ways.

Hello world

To try adding a new function, create a file helloWorld.ts in your component's folder (e.g. src/component/helloWorld.ts in the template):

./path/to/component/hello.ts
import { v } from "convex/values";
import { query } from "./_generated/server.js";

export const world = query({
args: {},
returns: v.string(),
handler: async () => {
return "hello world";
},
});

After it deploys, you can run npx convex run --component myComponent hello:world.

You can now also run it from a function in your app:

convex/sayHi.ts
import { components } from "./_generated/api";
import { query } from "./_generated/server";

export default query({
handler: async (ctx) => {
return await ctx.runQuery(components.myComponent.hello.world);
},
});

Try it out: npx convex run sayHi.

Key differences from regular Convex development

Developing a component is similar to developing the rest of your Convex backend. This section is a guide to the key concepts and differences.

The Component API

When you access a component reference like components.foo, you're working with the ComponentApi type which has some key differences from the regular api object:

  • Only public functions are accessible: Internal functions are not exposed to the parent app.
  • Functions become internal references: The component's "public" queries, mutations, and actions are turned into references with "internal" visibility. They can be called with ctx.runQuery, ctx.runMutation, etc. but not directly accessible from clients via HTTP or WebSockets. See below for patterns to re-export functions from the component.
  • IDs become strings: Any Id<"tableName"> arguments or return values become plain strings in the ComponentApi. See next section for details.

Similar to regular Convex functions, you can call both public and internal functions via npx convex run and the Convex dashboard.

Id types and validation

All Id<"table_name"> types within a component become simple string types outside of the component (in the ComponentApi type).

In addition, you cannot currently have a v.id("table_name") validator that represents a table in another component / app.

Why?

In Convex, a v.id("table_name") validator will check that an ID in an argument, return value, or database document matches the named table's format. Under the hood, this is currently encoded as a number assigned to each table in your schema.

Within a component’s implementation, the same applies to the component's tables. However, a v.id("users") within the component is not the same as v.id("users") in another component or in the main app, as each "users" table can have a different table number representation.

For this reason, all Id types in the ComponentApi become simple strings.

Generated code

Each component has its own _generated directory in addition to the convex/_generated directory. They are similar, but its contents are specific to the component and its schema. In general, code outside of the component should not import from this directory with the exception of _generated/component.

  • component.ts is only generated for components, and contains the component's ComponentApi type.
  • server.ts contains function builders like query and mutation to define your component's API. It's important that you import these when defining your component's functions, and not from convex/_generated/server. See below for more information on function visibility.
  • api.ts contains the component's api and internal objects to reference the component's functions. It also includes the components object with references to its child components, if any. In general, no code outside of the component should import from this file. Instead, they should use their own components object which includes this component keyed by whatever name they chose to install it with.
  • dataModel.ts contains the types for the component's data model. Note that the Id and Doc types here are not useful outside of the component, since the API turns all ID types into strings at the boundary.

Environment variables

The component's functions are isolated from the app's environment variables, so they cannot access process.env. Instead, you can pass environment variables as arguments to the component's functions.

return await ctx.runAction(components.sampleComponent.lib.translate, {
baseUrl: process.env.BASE_URL,
...otherArgs,
});

See below for other strategies for static configuration.

HTTP Actions

A component cannot expose HTTP actions itself because the routes could conflict with the main app's routes. Similar to other functions (queries, mutations, and actions), a component can define HTTP action handlers which the app can choose to mount. There’s an example in the Twilio component. All HTTP actions need to be mounted in the main app’s convex/http.ts file.

Authentication via ctx.auth

Within a component, ctx.auth is not available. You typically will do authentication in the app, then pass around identifiers like userId or other identifying information to the component.

This explicit passing makes it clear what data flows between the app and component, making your component easier to understand and test.

convex/myFunctions.ts
import { getAuthUserId } from "@convex-dev/auth/server";

export const someMutation = mutation({
args: {},
handler: async (ctx) => {
const userId = await getAuthUserId(ctx);
await ctx.runMutation(components.myComponent.foo.bar, {
userId,
...otherArgs,
});
},
});

Function Handles

Sometimes you want the app to call a component and the component should call back into the app.

For example, when using the Migrations component, the app defines a function that modifies a document, and the component runs this function on every document. As another example, an app using the Twilio component gives it a function to run whenever the phone number receives a text message.

These features are implemented using function handles.

A function reference is something like api.foo.bar or internal.foo.bar or components.counter.foo.bar. Function references are restricted as described above (a component can only use references to its own functions or the public functions of its children). If you have a valid function reference, you can turn it into something that can be called from anywhere:

const handle = await createFunctionHandle(api.foo.bar);

This handle is a string.

Since it’s a string, you can pass it between functions and even store it in a table. You would use v.string() in args/schema validators.

When you want to use it, cast it back to FunctionHandle and use it as you would use a function reference. Note argument and return value validation still run at runtime, so don't worry about losing guarantees.

const handle = handleString as FunctionHandle<"mutation">;

const result = await ctx.runMutation(handle, args);
// or run it asynchronously via the scheduler:
await ctx.scheduler.runAfter(0, handle, args);

Here is an example of using function handles in the Workpool component.

Pagination

The built-in .paginate() doesn't work in components, because of how Convex keeps track of reactive pagination. Therefore we recommend you use paginator from convex-helpers if you need pagination within your component.

If you expose a pagination API that wants to be used with usePaginatedQuery, in a React context, use the usePaginatedQuery from convex-helpers instead of the default one from convex/react. It will have a fixed first page size until you hit “load more,” at which point the first page will grow if anything before the second page is added.

Here is an example of pagination in the RAG component.

Tips and best practices

Validation

All public component functions should have argument and return validators. Otherwise, the argument and return values will be typed as any. Below is an example of using validators.

import schema from "./schema";

const messageDoc = schema.tables.messages.validator.extend({
_id: v.id("messages),
_creationTime: v.number(),
});

export const getLatestMessage = query({
args: {},
returns: v.nullable(messageDoc),
handler: async (ctx) => {
return await ctx.db.query("messages").order("desc).first();
},
});

Find out more information about function validation here.

Static configuration

A common pattern to track configuration in a component is to have a "globals" table with a single document that contains the configuration. You can then define functions to update this document from the CLI or from the app. To read the values, you can query them with ctx.db.query("globals").first();.

Wrapping the component with client code

When building a component, sometimes you want to provide a simpler API than directly calling ctx.runMutation(components.foo.bar, ...), add more type safety, or provide functionality that spans the component's boundary.

You can hide calls to the component's functions behind a more ergonomic client API that runs within the app's environment and calls into the component.

This section covers conventions and approaches to writing client code. These aren't hard and fast rules; choose the pattern that best fits your component's needs.

Note: An important aspect of this pattern is that the code running in the app has access to ctx.auth, process.env, and other app-level resources. For many use-cases, this is important, such as running code to define migrations in the app, which are then run from the Migrations Component. On the other hand, apps that want really tight control over what code runs in their app may prefer to call the component's functions directly.

Simple Function Wrappers

The simplest approach is to define standalone functions that wrap calls to the component. This works well for straightforward operations and utilities.

import type {
GenericActionCtx,
GenericDataModel,
GenericMutationCtx,
} from "convex/server";
import type { ComponentApi } from "../component/_generated/component.js";

export async function callMyFunction(
ctx: MutationCtx | ActionCtx,
component: ComponentApi,
args: ...
) {
// You can create function handles, add shared utilities,
// or do any processing that needs to run in the app's environment.
const functionHandle = await createFunctionHandle(args.someFn);
const someArg = process.env.SOME_ARG;
await ctx.runMutation(component.call.fn, {
...args,
someArg,
functionHandle,
});
}

// Useful types for functions that only need certain capabilities.
type MutationCtx = Pick<GenericMutationCtx<GenericDataModel>, "runMutation">;
type ActionCtx = Pick<
GenericActionCtx<GenericDataModel>,
"runQuery" | "runMutation" | "runAction"
>;

Note: we only use ctx.runMutation, so we can use Pick to select a type that only includes that function. This allows users to call it even if their ctx is not exactly the standard MutationCtx. It also means it can be called from an Action, as ActionCtx also includes ctx.runMutation. If your function also needs auth or storage, you can adjust what you Pick.

Re-exporting component functions

Sometimes you want to provide ready-made functions that apps can directly re-export to their public API. This is useful when you want to give apps the ability to expose your component's functionality to React clients or the public internet.

The most straightforward way to do this is have the user define their own functions that call into the component.

This allows the app to choose to add auth, rate limiting, etc.

convex/counter.ts
export const add = mutation({
args: { value: v.number() },
returns: v.null(),
handler: async (ctx, args) => {
// The app can authenticate the user here if needed
await ctx.runMutation(components.counter.add, args);
},
});

This is the recommended pattern, as it makes it clear to the user how the request is being authenticated. However, if you need to re-export a lot of functions, you can use the next pattern.

Re-mounting an API

Code in your src/client/index.ts can export these functions:

import type { Auth } from "convex/server";

// In your component's src/client/index.ts
export function makeCounterAPI(
component: ComponentApi,
options: {
// Important: provide a way for the user to authenticate these requests
auth: (ctx: { auth: Auth }, operation: "read" | "write") => Promise<string>;
},
) {
return {
add: mutation({
args: { value: v.number() },
handler: async (ctx, args) => {
await options.auth(ctx, "write");
return await ctx.runMutation(component.public.add, args);
},
}),

get: query({
args: {},
handler: async (ctx) => {
await options.auth(ctx, "read");
return await ctx.runQuery(component.public.get, {});
},
}),
};
}

Then apps can mount these in their own API:

// In the app's convex/counter.ts
import { makeCounterAPI } from "@convex-dev/counter";
import { components } from "./_generated/server.js";

export const { add, get } = makeCounterAPI(components.counter, {
auth: async (ctx, operation) => {
const userId = await getAuthUserId(ctx);
// Check if the user has permission to perform the operation
if (operation === "write" && !userId) {
throw new Error("User not authenticated");
}
return userId;
},
});

This pattern is also useful for components that need to provide functions with specific signatures for integration purposes.

Here's a real-world example from the ProseMirror component that exports ready-made functions.

Class-Based Clients

For more complex components, a class-based client provides a stateful interface that can hold configuration and provide multiple methods.

Basic class pattern:

import Foo from "@convex-dev/foo";
import { components } from "./_generated/server.js";

const foo = new Foo(components.foo, {
maxShards: 10,
});

With configuration options:

Classes typically accept the component reference as their first argument, with optional configuration as the second:

export class Foo {
private apiKey: string;

constructor(
public component: ComponentApi,
options?: {
maxShards?: number;
// Named after the environment variable it overrides, for clarity.
FOO_AUTH_KEY?: string;
},
) {
this.apiKey = options?.FOO_AUTH_KEY ?? process.env.FOO_AUTH_KEY!;
}

async count(ctx: GenericQueryCtx<GenericDataModel>) {
return await ctx.runQuery(this.component.public.count, {
API_KEY: this.apiKey,
});
}
}

Dynamic instantiation: Note that clients don't need to be instantiated statically. If you need runtime values, you can create instances dynamically:

export const myQuery = query({
handler: async (ctx, args) => {
const foo = new Foo(components.foo, {
apiKey: args.customApiKey,
});
await foo.count(ctx);
},
});

Building and publishing NPM package components

Build process

While developing a component that will be bundled, the example app that installs and exercises it will import the bundled version of the component. This helps ensure that the code you are testing matches the code that will be published.

However, that means npx convex dev cannot detect where the original source code is located for the component, and will not automatically generate the code for the component. When developing a component that will be bundled, you need to run a separate build process to generate the component's _generated directory.

The component authoring template will automatically generate the code for the component when running npm run dev. You can see the setup in the template's package.json scripts.

If you're setting up your own build process, you'll need to run the following commands with their own file watchers:

  1. Component codegen: Generate code for the component itself

    npx convex codegen --component-dir ./path/to/component
  2. Build the package: Build the NPM package

    npm run build # Your build command (e.g., tsc, esbuild, etc.)
  3. Example app codegen & deploy: Generate code for the example app and deploy it app

    npx convex dev --typecheck-components # optionally type-check the components

Note on ordering: The ideal ordering is: component codegen → build the package → example app convex dev runs. This is a recommended convention followed by the template to avoid builds racing with each other, but the key requirement is that the component must be built and available before the example app tries to import it.

Entry points

When publishing a component on NPM, you will need to expose all the relevant entry points to be used in your project:

  • @your/package exports types, classes, and constants used to interact with the component from within their app's code. This is optional, but common.
  • @your/package/convex.config.js exposes the component's config.
  • @your/package/_generated/component.js exports the ComponentApi type, which describes the component's types from the point of view of app it's used in.
  • @your/package/test for utilities to use the component with convex-test.

The template’s package.json does this for you, but if you're setting up your own build process, you'll need to set this up in your package.json.

Local package resolution for development

When developing a component, you generally want to be importing the component's code in the same way that apps will import it, e.g. import {} from "@your/package". To achieve this without having to install the package from NPM in the example app, follow the template's project structure:

  1. In the root of the project, have the package.json with the package name matching the @your/package name. This causes imports for that name to resolve to the package.json’s exports.
  2. In the exports section of the package.json, map the aforementioned entry points to the bundled files, generally in the dist directory. This means imports from the package name will resolve to the bundled files.
  3. Have a single package.json file and node_modules directory in the root of the project, so the example app will resolve to the package name by default. This will also avoid having multiple versions of convex referenced by the library vs. the example app. To add dependencies used only by the example app, add them as devDependencies in the package.json.

Publishing to NPM

To publish a component on NPM, check out PUBLISHING.md.

Testing

Testing implementations

To test components, you can use the convex-test library. The main difference is that you must provide the schema and modules to the test instance.

component/some.test.ts
import { test } from "vitest";
import { convexTest } from "convex-test";
import schema from "./schema.ts";
const modules = import.meta.glob("./**/*.ts");

export function initConvexTest() {
const t = convexTest(schema, modules);
return t;
}

test("Test something with a local component", async () => {
const t = initConvexTest();
// Test like you would normally.
await t.run(async (ctx) => {
await ctx.db.insert("myComponentTable", { name: "test" });
});
});

If your component has child components, see the Testing components section in the Using Components documentation.

Testing the API and client code

To test the functions that are exported from the component to run in the app's environment, you can follow the same approach as in Using Components and test it from an app that uses the component.

The template component includes an example app in part for this purpose: to exercise the component's bundled code as it will be used by apps installing it.

Exporting test helpers

Most components export testing helpers to make it easy to register the component with the test instance. Here is an example from the template component’s /test entrypoint:

/// <reference types="vite/client" />
import type { TestConvex } from "convex-test";
import type { GenericSchema, SchemaDefinition } from "convex/server";
import schema from "./component/schema.js";
const modules = import.meta.glob("./component/**/*.ts");

/**
* Register the component with the test convex instance.
* @param t - The test convex instance, e.g. from calling `convexTest`.
* @param name - The name of the component, as registered in convex.config.ts.
*/
export function register(
t: TestConvex<SchemaDefinition<GenericSchema, boolean>>,
name: string = "sampleComponent",
) {
t.registerComponent(name, schema, modules);
}
export default { register, schema, modules };

For NPM packages, this is exposed as @your/package/test in the package's package.json:

{
...
"exports": {
...
"./test": "./src/test.ts",
...
}
}