Beta . Ortha CMS is in public beta. Every plan is free until it ends.
Features Ortha CMS on GitHub

Plugins

The unit of capability in Ortha, and the two contracts a plugin implements.

Documents 0.13.1 Updated Edit this page Report a problem

On this page

Everything in Ortha is a plugin — auth, content, media, the audit log, the copilot, MCP. The host owns no domain logic. You add a capability by writing a plugin and adding one line to a list; you never edit the host.

Two contracts

packages/<group>/server   → ServerPlugin  (a NestJS module + migrations)
packages/<group>/admin    → AdminPlugin   (routes, layout, slot contributions)
packages/<group>/domain   → optional framework-free kernel
packages/<group>/provider-* → optional adapters for a swappable backend
// @orthacms/bootstrap-server
interface ServerPlugin {
    name: string;
    module: Type | DynamicModule;
    onPluginInit?(): void | Promise<void>;
    migrations?: { dir: () => string; table: string };
    docs?: PluginApiDocs;
}

// @orthacms/bootstrap-admin
type AdminPlugin = {
    name: string;
    routes?: RouteItem[];
    layout?: ReactNode;
    slots?: SlotContribution[];
};

Registering a plugin

Each runtime has one function returning an array. Add your factory at the end.

// apps/server/src/plugins.ts
import type { ServerPlugin } from '@orthacms/bootstrap-server';
import { DatabasePlugin } from '@orthacms/database';
import { IdentityPlugin } from '@orthacms/identity-server';
import { WorkspacesPlugin } from '@orthacms/workspaces-server';
import { ReviewsPlugin } from '@acme/reviews-server';
import type { OrthaCmsConfig } from '../orthacms.config';

export function buildPlugins(config: OrthaCmsConfig): ServerPlugin[] {
    return [
        DatabasePlugin({ connectionString: config.database.url }),
        IdentityPlugin(config.plugins.identity),
        WorkspacesPlugin(),
        // …the rest of the shipped plugins…
        ReviewsPlugin({ maxBodyLength: 2000 })
    ];
}
// apps/admin/src/plugins.ts — main.tsx calls createAdmin({ plugins: buildPlugins() })
import type { AdminPlugin } from '@orthacms/bootstrap-admin';
import { IdentityPlugin } from '@orthacms/identity-admin';
import { ShellPlugin } from '@orthacms/shell-admin';
import { WorkspacesPlugin } from '@orthacms/workspaces-admin';
import { ReviewsPlugin } from '@acme/reviews-admin';

export function buildPlugins(): AdminPlugin[] {
    return [IdentityPlugin(), ShellPlugin(), WorkspacesPlugin(), ReviewsPlugin()];
}

The same two files exist in an app made with create-orthacms-app.

Where each piece goes

The rest of this section builds one small plugin — reviews, comments left on a content entry — one piece per page.

I want to…APIPage
Add an HTTP route gated on a permission@RequirePermissions, WorkspaceGuardServer plugins
Add a page and a sidebar entryWORKSPACE_ROUTE_SLOT, WORKSPACE_NAV_SLOTAdmin plugins
Put UI into another plugin’s screenENTRY_SIDEBAR_WIDGET_SLOT and 30 other slotsSlots
Own a table and ship its migrationDrizzle schema + migrations descriptorDatabase access
React after something happenedOutboxDispatcher.registerDomain events
Change what another plugin does, inside its transactionA DI port or a registryDI ports
Give the copilot or MCP a toolToolRegistry.registerAgent tools
Test itcreateTestApp, resetDb, Playwright mocksTesting

Plugins extend each other without depending on each other

  • Admin — slots. The plugin that renders a place defines a slot; others contribute items as data.
  • Server — DI ports. The plugin that wants extending declares a Symbol token and injects it @Optional(); the extending plugin binds it.

Content declares the entry-extension port and knows nothing about locales; i18n binds it. Remove i18n and content degrades instead of failing to boot.

Cross-plugin foreign keys

Default to a plain uuid column and enforce the relationship in your service. That keeps your plugin removable.

A few shipped tables do take the constraint — memberships, saved views and copilot conversations reference users / workspaces through a reference-only projection (external-refs.ts). Each one makes plugin order load-bearing.

Order in the plugin list

Every onPluginInit runs before Nest creates the app, and every plugin module is global, so order does not affect dependency injection.

Order does decide:

  • Migrations — applied in list order. Identity (users) before workspaces (memberships), or a fresh migrate fails with relation "users" does not exist.
  • Init hooks that depend on an earlier hook’s side effect.
  • API documentation passes (docs.decorate), last writer wins.
  • Admin: which layout is mounted (first wins), tie-breaks within a slot, and last-writer-wins merges such as Insights sections.

Read before you write

  • The package AGENTS.md next to whatever you are extending.
  • The skills under .agents/skills/ — server-plugin, admin-plugin, server-e2e, admin-e2e, accessibility. They are the house style.

No marketplace, no runtime loading

A plugin is a package the app imports and lists. There is no registry, no install command and no versioned plugin API — adding one is a code change and a deploy. The shipped packages are on npm under @orthacms, so a plugin can be an ordinary npm dependency.