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… | API | Page |
|---|---|---|
| Add an HTTP route gated on a permission | @RequirePermissions, WorkspaceGuard | Server plugins |
| Add a page and a sidebar entry | WORKSPACE_ROUTE_SLOT, WORKSPACE_NAV_SLOT | Admin plugins |
| Put UI into another plugin’s screen | ENTRY_SIDEBAR_WIDGET_SLOT and 30 other slots | Slots |
| Own a table and ship its migration | Drizzle schema + migrations descriptor | Database access |
| React after something happened | OutboxDispatcher.register | Domain events |
| Change what another plugin does, inside its transaction | A DI port or a registry | DI ports |
| Give the copilot or MCP a tool | ToolRegistry.register | Agent tools |
| Test it | createTestApp, resetDb, Playwright mocks | Testing |
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
Symboltoken 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 freshmigratefails withrelation "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
layoutis mounted (first wins), tie-breaks within a slot, and last-writer-wins merges such as Insights sections.
Read before you write
- The package
AGENTS.mdnext 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.