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

Plugins

Every plugin a deployment can register, the two arrays that compose your CMS, and how to call admin routes.

Documents 0.13.1 Updated Edit this page Report a problem

On this page

An Ortha deployment is its plugin list: one array for the API, one for the admin. Add a capability by installing a package and adding a line; remove one by deleting the line.

The two arrays

This is what create-orthacms-app generates (optional plugins marked):

// apps/server/src/plugins.ts
export function buildPlugins(config: OrthaCmsConfig): ServerPlugin[] {
    const content = ContentPlugin({
        types: contentTypes, // the generated manifest, src/content/index.ts
        migrations: {
            dir: () => join(__dirname, '../../../apps/server/migrations'),
            table: '__drizzle_migrations_content'
        }
    });

    return [
        DatabasePlugin({
            connectionString: config.database.url,
            outboxRetentionDays: config.database.outboxRetentionDays
        }),
        IdentityPlugin(config.plugins.identity),
        WorkspacesPlugin(),
        ActivityPlugin(),
        ...mailPlugin(config), // only when MAIL_PROVIDER is set
        UsersPlugin(),
        content,
        ContentViewsPlugin({ content }),
        ContentGraphqlPlugin({ content, ...config.plugins.contentGraphql }), // optional
        SchemaBuilderPlugin(config.plugins.schemaBuilder),
        I18nServerPlugin(config.plugins.i18n),
        AlarmsPlugin(),
        WebhooksPlugin(config.plugins.webhooks),
        MediaServerPlugin({
            provider: createLocalStorageProvider(config.plugins.media.storage),
            config: config.plugins.media
        }),
        TransferPlugin(config.plugins.transfer),
        SegmentsPlugin(config.plugins.segments),
        ProtectionPlugin(),
        CopilotPlugin({ providers: copilotProviders(config), config: config.plugins.copilot }),
        McpPlugin({ config: config.plugins.mcp }) // optional, last
    ];
}
// apps/admin/src/plugins.ts
export function buildPlugins(): AdminPlugin[] {
    return [
        IdentityPlugin(), // first: public sign-in / accept-invite routes
        ShellPlugin(), // second: owns the layout and the auth gate
        WorkspacesPlugin(),
        InsightsPlugin(),
        ContentPlugin(),
        I18nPlugin(),
        WysiwygPlugin(),
        MediaPlugin(),
        transferAdminPlugin(),
        AlarmsPlugin(),
        CopilotPlugin(),
        SegmentsPlugin(),
        ProtectionPlugin(),
        UsersPlugin(),
        ActivityPlugin(),
        ApiTokensPlugin(),
        WebhooksPlugin(),
        SchemaBuilderPlugin()
    ];
}

The catalogue

PluginServerAdminOwns tablesEnv vars
Databasedatabase—yesDATABASE_URL, OUTBOX_RETENTION_DAYS
Identityidentity-serveridentity-adminyesALLOWED_ORIGINS, SESSION_TTL_SECONDS, SSO_*, …
Workspacesworkspaces-serverworkspaces-adminyes—
Activityactivity-serveractivity-adminyes—
Mailmail-server—yesMAIL_*, SMTP_*, APP_URL
Usersusers-serverusers-adminno—
Contentcontent-servercontent-adminyes—
Saved viewscontent-servercontent-adminyes—
GraphQLcontent-graphql—noGRAPHQL_*
Schema builderschema-builder-serverschema-builder-adminnoSCHEMA_BUILDER, SCHEMA_BUILDER_*
Mediamedia-servermedia-adminyesMEDIA_*
Localizationi18n-serveri18n-adminno— (config/i18n.ts)
Transfertransfer-servertransfer-adminno—
Alarmsalarms-serveralarms-adminyes—
Segmentssegments-serversegments-adminyesSEGMENTS_READER_TAGS_HEADER
Protectionprotection-serverprotection-adminyes—
Webhookswebhooks-serverwebhooks-adminyesWEBHOOKS_*
Copilotcopilot-servercopilot-adminyesCOPILOT_*, ANTHROPIC_*
MCPmcp-server—noMCP_*
Shell—shell-adminno—
Rich text—wysiwyg-adminno—
Insights—insights-adminno—
API tokens—api-tokens-adminno—
  • Every package is published to npm under @orthacms, in lockstep — one version across the set.
  • Saved views is a second ServerPlugin entry from @orthacms/content-server, not its own package.
  • Localization’s locale / locale_group_id columns live on content’s tables.
  • The full environment table is in configuration.

Upgrade them together

@orthacms/* dependencies are pinned exactly. A partial upgrade can leave two copies of a shared package in node_modules — two React contexts, and an admin whose sidebar silently stops talking to its provider.

Calling admin routes

Two API surfaces, two credentials:

SurfacePrefixCredentialWorkspace
Public delivery API, GraphQL, MCP/api/v1/…Authorization: Bearer orthacms_…X-Workspace-Id when the token covers several
Admin API (every route on the plugin pages)/api/…orthacms_session cookieX-Workspace-Id on workspace-scoped routes

A script calls admin routes by signing in once and reusing the cookie:

Example language
curl -c cookies.txt https://cms.example.com/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"admin@example.com","password":"…"}'
# { "ok": true } and a Set-Cookie: orthacms_session=…

curl https://cms.example.com/api/protection/queue \
-b cookies.txt \
-H 'X-Workspace-Id: 3f7a…'
const login = await fetch('https://cms.example.com/api/auth/login', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ email: 'admin@example.com', password: process.env.ORTHA_PASSWORD })
});
const cookie = login.headers.get('set-cookie').split(';')[0]; // orthacms_session=…

const queue = await fetch('https://cms.example.com/api/protection/queue', {
  headers: { Cookie: cookie, 'X-Workspace-Id': workspaceId }
}).then((r) => r.json());
  • Writes pass OriginGuard: a request with no Origin header (curl, Node) or one listed in ALLOWED_ORIGINS is accepted; any other origin is 403.
  • The workspace id is the uuid in the admin URL (/workspaces/<id>/…) and on the workspace’s settings page.
  • A permission the caller lacks is 403; routes list the key they need.
  • Every registered plugin’s routes are in the OpenAPI reference at /reference (/reference/json raw) — on outside production, or with API_DOCS=true.

Order is migration order

Server plugins run their migrations in list order, with no transaction across plugins. A plugin whose tables reference another’s must come after it.

  • WorkspacesPlugin after IdentityPlugin — memberships references users. Reversed, a fresh orthacms migrate fails with relation "users" does not exist, while an existing database migrates fine.
  • ContentViewsPlugin after identity, workspaces and content.
  • Add new plugins at the end unless you have a reason not to.

Order is not dependency injection: every plugin module is global and every onPluginInit runs before the Nest app is created. In the admin, order decides only the order of slot items and which plugin wins an id collision — plus the two fixed positions above (identity first, shell second).

Config and registrations

A plugin takes at most two kinds of input:

  • Config — typed values built from the environment in apps/server/config/*.ts, assembled in orthacms.config.ts, read only through @orthacms/utils-server readers (so a blank .env value counts as absent).
  • Registrations — constructed objects (a storage backend, a model provider, an SSO provider), created in plugins.ts.
// apps/server/config/segments.ts — config built from the environment
import { headerSegmentResolver, type SegmentsPluginConfig } from '@orthacms/segments-server';
import { readEnv } from '@orthacms/utils-server';

export function segmentsConfig(): SegmentsPluginConfig {
    return {
        resolver: headerSegmentResolver(readEnv('SEGMENTS_READER_TAGS_HEADER') ?? 'x-reader-tags')
    };
}
// apps/server/src/plugins.ts — a registration: a constructed adapter
MediaServerPlugin({
    provider: createS3StorageProvider(config.plugins.media.storage),
    config: config.plugins.media
});

Pluggable backends

Four plugins take a port and a backend constructed at the composition root:

PluginPortShipped adapters
MediaStorageProviderlocal, S3, Azure, GCS, Vercel, memory
CopilotModelProviderClaude, OpenAI-wire
IdentitySsoProviderOIDC, GitHub, SAML, scripted
MailMailProviderSMTP, console, testkit
  • Storage, model and SSO ports ship a conformance kit — see writing a storage, model or SSO provider. Mail has no kit yet.
  • A capability a backend declares is checked at boot, not at the first request.
  • The copilot’s scripted backend is a private test fixture: a deployment with no model key has no copilot.

Extension points at a glance

Port or slotPackageUsed by
contentPublishGuardRegistrarcontent-serverProtection
OutboxDispatcher.registerdatabaseActivity, webhooks, protection — see database
INSIGHTS_WIDGET_SLOTinsights-adminInsights
ENTRY_STATUS_ITEM_SLOT, ENTRY_TIMELINE_SLOTcontent-adminAlarms, activity — see the entry editor’s slots
SIDEBAR_NAV_SLOT, HOME_SECTION_SLOT, COMMAND_SLOTshell-adminShell
WYSIWYG_MEDIA_SLOTwysiwyg-adminRich text

See writing a plugin and slots for the full contracts.