Creating an extension
Create a trusted Kurrier extension with separate server, worker, and web entry points.
Let's create an Email Templates extension.
It will contribute a dashboard page and navigation item. We will keep runtime-specific entry points separate so each Kurrier runtime only loads what it needs.
1. Create the feature directory
For an OSS feature:
packages/oss/src/extensions/
└── email-templates/
├── server.ts
├── worker.ts
├── web.ts
└── email-templates-page.tsxOnly create the entry points the feature needs. The feature directory can also contain normal components, actions, helpers, schemas, job handlers, or other trusted application code.
2. Create the dashboard page
Create email-templates/email-templates-page.tsx:
export default function EmailTemplatesPage() {
return (
<div>
<h1 className="text-2xl font-semibold">Email Templates</h1>
<p className="text-muted-foreground">
Create and manage reusable email templates.
</p>
</div>
);
}This is a normal React component.
Do not recreate Kurrier's dashboard shell, sidebar, header, or content framing. Kurrier owns the surrounding dashboard UI.
3. Create the web extension
Create email-templates/web.ts:
import type { KurrierExtension } from "@extensions";
import EmailTemplatesPage from "./email-templates-page";
export const emailTemplatesWebExtension = {
manifest: {
id: "oss.email-templates",
name: "Email Templates",
},
contributions: {
navigation: {
dashboard: [
{
id: "email-templates",
title: "Email Templates",
path: "platform/extensions/email-templates",
icon: "FileText",
},
],
},
pages: {
dashboard: [
{
id: "email-templates",
path: "email-templates",
component: EmailTemplatesPage,
},
],
},
},
} satisfies KurrierExtension;Every extension has a manifest. id uniquely identifies the extension and name is its human-readable name.
Everything else is optional.
4. Add server behavior only when needed
If the feature does not need lifecycle hooks, server.ts can be omitted.
If it does, create a separate server extension:
import type { KurrierExtension } from "@extensions";
import { beforeWorkspaceCreate } from "./before-workspace-create";
export const emailTemplatesServerExtension = {
manifest: {
id: "oss.email-templates",
name: "Email Templates",
},
hooks: {
"workspace.beforeCreate": beforeWorkspaceCreate,
},
} satisfies KurrierExtension;Keeping server and web definitions separate prevents unrelated runtime code from being pulled into the wrong application.
5. Add background behavior only when needed
If the feature needs to process queued work, expose a worker contribution from worker.ts.
Conceptually:
import type { KurrierExtension } from "@extensions";
export const emailTemplatesWorkerExtension = {
manifest: {
id: "oss.email-templates",
name: "Email Templates",
},
contributions: {
workers: [
{
queue: "email-templates",
concurrency: 1,
handler: async (job) => {
// Process the job.
},
},
],
},
} satisfies KurrierExtension;If the feature also needs recurring work, it can contribute a scheduler:
contributions: {
workers: [
{
queue: "email-templates",
concurrency: 1,
handler: processEmailTemplateJob,
},
],
schedulers: [
{
queue: "email-templates",
id: "email-templates-maintenance",
jobName: "email-templates:maintenance",
every: 60 * 60 * 1000,
},
],
},Worker and scheduler contributions are declarative.
Do not instantiate BullMQ Worker or JobScheduler objects inside the extension. Kurrier's Nitro worker runtime owns their lifecycle.
Use the exact current contribution types in packages/extensions/src/ when implementing a feature.
6. Add the extension to the runtime lists
Packages expose separate extension collections for the runtimes they support.
For example, extensions/web.ts:
import type { KurrierExtension } from "@extensions";
import { emailTemplatesWebExtension } from "./email-templates/web";
export const webExtensions = [
emailTemplatesWebExtension,
] satisfies KurrierExtension[];When server hooks are needed, extensions/server.ts:
import type { KurrierExtension } from "@extensions";
import { emailTemplatesServerExtension } from "./email-templates/server";
export const serverExtensions = [
emailTemplatesServerExtension,
] satisfies KurrierExtension[];When background processing is needed, extensions/worker.ts exposes the worker-side extensions for the package.
The active distribution adapts these lists and registers them for the appropriate runtime.
Do not call registerExtension() from feature code.
7. Enqueue jobs through shared queue infrastructure
A feature that needs to submit work should use Kurrier's shared queue infrastructure rather than constructing unrelated queue infrastructure inside the feature.
The shared queue helpers live in @common. The current source is authoritative for the exact API.
This separation is intentional:
feature/action
↓
shared queue
↓
Nitro worker
↓
registered extension worker
↓
feature handlerThe producer does not need to own the worker lifecycle.
8. Build the feature normally
The extension definition is only the feature's connection to Kurrier.
The rest can be organized like any other feature:
email-templates/
├── server.ts
├── worker.ts
├── web.ts
├── email-templates-page.tsx
├── actions.ts
├── jobs/
├── components/
└── lib/Trusted extensions can use normal Kurrier application packages and server capabilities.
Adding an auth page
Extensions can also contribute authenticated auth-flow pages:
pages: {
auth: [
{
id: "template-import",
path: "template-import",
component: TemplateImportPage,
},
],
},Kurrier resolves the contributed route and renders the extension component inside the auth routing boundary.
A contributed page can also specify its own inner layout when the feature needs additional framing. Kurrier still owns the outer application route.
Need lifecycle behavior?
See Hooks for the typed server hook API.
Working examples
The current source tree is the best reference:
packages/oss/src/extensions/
packages/extensions/src/Look for features with separate runtime entry points and use the current types as the final API reference.