CMS UI extensions
Beta
The following feature is in beta.
A CMS UI extension is an OCP app that contributes UI into Optimizely CMS.
Each surface is an injection point, the key you declare the extension under in app.yml. OCP supports the following injection points:
- sidebar – Content editor side panel. Mounts a panel next to the content editor, alongside the content the editor is working on.
- view – Full page. Mounts a standalone, full-width page that the editor opens from the CMS navigation. It is not attached to a content item.
- property-editor – Property editor. Replaces the built-in editor for a single content property of a declared type, such as a string or a Boolean. The extension reads and writes the value of the property it is bound to.
Additional panel types are planned for future releases. The ui_extensions: schema is designed so each new surface lands as another injection point alongside the existing ones.
All injection points use identical SDKs, manifest entry shapes, backend proxy mechanisms, and build outputs. A property-editor entry also declares the property type it edits in a metadata block. An extension targets a surface through the declared ui_extensions: key and the entry filename. See Choose an injection point.
Architecture
Unlike other OCP app types, a CMS UI extension app has code that runs in two separate runtimes:
Half | Where it runs | Source location | Build output | Talks to |
|---|---|---|---|---|
Frontend (UI bundle) | The end user's browser, inside an iframe hosted by Optimizely CMS | src/cms-ui-extensions/ | dist/cms-ui-extensions/<entry_point>.js (ESM) | The backend half through context.extension.invokeFunction(), and the CMS host through ExtensionContext |
Backend (functions, jobs, lifecycle, libs) | The OCP platform, on the node22-cms-ext runtime | src/backend/ | dist/ (CommonJS, mirrors the src/backend/ subtree) | External APIs over HTTPS, and OCP storage and lifecycle APIs |
Each half is built independently and ships in the same OCP app package. The two halves communicate only through the typed RPC channel exposed by context.extension.invokeFunction(). There is no shared memory, no direct module import across the boundary, and no HTTP endpoint exposed publicly. API keys never reach the browser, no CORS configuration is required, and authentication and rate-limit handling live in one place on the backend.
┌─────────────────────────────┐
│ Browser (Optimizely CMS) │
│ ┌─────────────────────────┐ │ invokeFunction({action, params})
│ │ Iframe │ │ ────────────────────────────────────┐
│ │ Frontend (UI bundle) │ │ │
│ │ src/cms-ui-extensions │ │ │
│ └─────────────────────────┘ │ │
└─────────────────────────────┘ ▼
┌──────────────────────────┐
│ OCP platform │
│ Backend function │
│ src/backend │
│ runtime: node22-cms-ext │
└────────────┬─────────────┘
│ HTTPS + credentials
▼
┌──────────────────┐
│ External API │
└──────────────────┘A CMS UI extension app consists of:
- A dedicated runtime declared in app.yml: runtime: node22-cms-ext.
- Two SDK packages as dependencies (see SDK packages): @optimizely/cms-extensibility-sdk for the frontend bundle and @optimizely/ocp-cms-ui-extensions-sdk for the backend build.
- An ocp-app.config.mjs file that registers the cmsUiExtensions() plugin so the OCP app SDK builds and validates the UI bundle.
- A ui_extensions: section in app.yml that declares each UI entry and the surface where it mounts.
- At least one UI entry file under src/cms-ui-extensions/, matching the pattern <Name>.<injection-point>.{ts,tsx,js,jsx} (for example, MyExtension.sidebar.tsx, MyGallery.view.tsx, or MyColorPicker.property-editor.tsx).
The frontend half often needs to reach a server, for example, to call a third-party API that requires a secret key, to read or write OCP storage, or to perform work the browser cannot do safely. In those cases, add one or more backend functions with accepts: cms_ui_extension and invoke them from the UI through context.extension.invokeFunction(). Most production extensions ship with at least one backend function, but extensions that only render static content, talk to a public API directly, or rely entirely on data from context.content can skip the backend half. Backend functions, jobs, lifecycle hooks, and settings forms keep their standard src/ paths and APIs.
These pieces apply to CMS UI extension apps only. Other OCP app types (data sync source, data sync destination, Agent Platform tool, or generic) keep their existing layout, manifest, and runtime unchanged.
Important
The frontend half ships to the end user's browser. Every byte of it is inspectable by anyone with developer tools. Never embed API keys, OAuth client secrets, signed URLs, customer data the editor should not see, or any other sensitive value in the UI bundle or in src/shared/. Keep secrets in OCP storage or environment variables, and perform any operation that requires a secret (third-party API calls, token exchange, signing, sensitive data access) inside a backend function. The UI calls the backend through context.extension.invokeFunction() and never touches the secret directly.
SDK packages
A CMS UI extension app depends on two SDKs. They are separate packages because they target separate runtimes — one ships in the browser bundle, the other runs at app build time on the backend.
@optimizely/cms-extensibility-sdk – frontend SDK
The official SDK for building UI Extensions for Optimizely CMS. Add it as a runtime dependency, import it in your UI entry files, and use it to register your extension and talk to the CMS host:
- register(factory) – Entry point of every extension. Mounts your React tree.
- context.extension – Methods for the extension's lifecycle and for calling backend functions (setReady, getDefinition, invokeFunction).
- context.content – Methods for reading and reacting to the content the editor is viewing (get, subscribe).
- context.property – Methods for reading, writing, and reacting to the value of the property a property-editor extension is bound to (get, set, subscribe). Requires version 1.1.0 or later.
Public reference: npmjs.com/package/@optimizely/cms-extensibility-sdk. Treat that page as the source of truth for the API surface. This document covers OCP-specific integration on top of it.
@optimizely/ocp-cms-ui-extensions-sdk – OCP app SDK plugin
The build-time plugin that teaches the OCP app SDK about CMS UI extensions. You add it as a dev/build dependency and wire it into ocp-app.config.mjs:
import {defineConfig} from '@zaiusinc/app-sdk';
import {cmsUiExtensions} from '@optimizely/ocp-cms-ui-extensions-sdk';
export default defineConfig({
plugins: [cmsUiExtensions()]
});After registration, the plugin does the following:
- Extends the app.yml schema with the ui_extensions: section and the accepts: cms_ui_extension function attribute so ocp app validate accepts them.
- Provides backend TypeScript types for the manifest shapes.
- Runs build-time validators that check ui_extensions: entries for duplicate names, missing display names, unsupported property types, and missing bundle files in dist/cms-ui-extensions/.
This SDK is never imported from frontend code.
Note
The property-editor injection point requires @optimizely/ocp-cms-ui-extensions-sdk version 1.1.0-beta.1 or later and @optimizely/cms-extensibility-sdk version 1.1.0 or later. Earlier versions of the plugin reject the property-editor key and the metadata field, and earlier versions of the frontend SDK do not provide context.property.
Sample app
The Unsplash Viewer sample app demonstrates the full extension surface, including the UI, backend proxy, settings form, and credential validation. It targets the sidebar injection point; a view extension uses the same structure with a .view.tsx entry file and a view: key in the manifest. Fork it as a starting point.
Choose an injection point
The injection points differ in where the CMS mounts the UI and in how much the UI assumes what the editor is doing.
| sidebar | view | property-editor |
|---|---|---|---|
Surface | Panel beside the content editor | Standalone full page | In place of the built-in editor for one property |
Opened from | The content editor | The CMS navigation | The content editor, for a property of a matching type |
Available space | Narrow and vertical | Full width of the content area | The width of a single form field |
Bound to a content item | Yes | No | Yes, and to one property of that item |
context.content | Reports the content the editor is viewing | Not content-scoped. Do not depend on it. | Reports the content the editor is viewing |
context.property | Not bound. Returns an empty, read-only state. | Not bound. Returns an empty, read-only state. | Reads and writes the bound property's value |
Use sidebar when the UI supports the content the editor is working on, such as an asset picker that inserts into the current page, a checklist for the open draft, or a content-aware assistant. The panel is narrow, so design for a single column.
Use view when the UI is a destination in its own right and does not depend on a specific content item, such as a browsable media library, a dashboard, a bulk import screen, or a settings and reporting page. The page has the full content area, so multi-column layouts, tables, and grids work well.
Note
A view extension has no current content item. Do not assume context.content resolves to a content item in a view. The full page is not opened in the context of a content item, so content.get() and content.subscribe() do not report one. Write view extensions to work without it, and keep any content-scoped behavior in a sidebar extension.
Use property-editor when a single field needs a richer input than the CMS provides, such as a color picker for a string, a slider for an integer, or a lookup that stores an ID from an external system. The extension owns only that one value. It reads it with context.property.get() and writes it back with context.property.set(). See Property editors.
An app can declare any combination, and the extensions can share components and a backend function. Build the shared UI once and mount it from each entry file, adjusting only the layout.
Manifest
runtime: node22-cms-ext
functions:
cms_extension:
entry_point: CmsUiExtension
description: Backend proxy for the UI extensions.
accepts: cms_ui_extension
ui_extensions:
sidebar:
- name: my-extension
entry_point: MyExtension
display_name: My Extension
view:
- name: my-gallery
entry_point: MyGallery
display_name: My Gallery
property-editor:
- name: my-color-picker
entry_point: MyColorPicker
display_name: My Color Picker
metadata:
property_type: string
property_format: shortstringThe ui_extensions: section maps each injection point to a list of UI entries. Supported injection points are sidebar (the content editor side panel), view (a full page), and property-editor (an editor for a single content property). Declare any combination of keys. A single backend function can serve every extension in the app. Each entry requires:
- name – Unique identifier for the extension within the app.
- entry_point – Basename of the source file under src/cms-ui-extensions/, without the injection-point suffix and extension. The build emits the matching bundle to dist/cms-ui-extensions/<entry_point>.js.
- display_name – Label that the CMS editors can view.
A property-editor entry also requires a metadata block. See Property editor metadata. sidebar and view entries do not use metadata.
name and entry_point must be unique across the whole ui_extensions: section, not only within one injection point.
Property editor metadata
The metadata block of a property-editor entry tells the CMS which properties the editor applies to:
- property_type – (Required) The type of property the extension edits.
- property_format – (Optional) A refinement of the type. Omit it unless the type supports a format.
The following combinations are supported:
property_type | property_format |
|---|---|
string | Omitted, or shortstring |
richtext | Omitted |
integer | Omitted |
float | Omitted |
boolean | Omitted |
Values are case-insensitive. ocp app validate rejects any other combination, so an unsupported type fails the build instead of producing an editor that the CMS never mounts. To edit properties of different types, declare one property-editor entry for each type.
ui_extensions: is not part of the base OCP manifest schema. It is contributed by @optimizely/ocp-cms-ui-extensions-sdk and only recognized when the cmsUiExtensions() plugin is registered in ocp-app.config.mjs. The same applies to the accepts: cms_ui_extension attribute on functions. Without that plugin registration, ocp app validate rejects both as unknown fields.
Implement the extension
UI entry files live under src/cms-ui-extensions/ (the build looks nowhere else). Each filename follows the pattern <Name>.<injection-point>.{ts,tsx,js,jsx}, where:
- <Name> matches the entry_point value in app.yml for this extension.
- <injection-point> matches the section key under ui_extensions:, sidebar, view, or property-editor.
For the preceding manifest, the entry files are src/cms-ui-extensions/MyExtension.sidebar.tsx, src/cms-ui-extensions/MyGallery.view.tsx, and src/cms-ui-extensions/MyColorPicker.property-editor.tsx. Files outside this directory or named differently are ignored by the build and do not appear in the output bundle, and ocp app validate reports a missing bundle.
The injection-point suffix is what binds the file to a surface. Renaming MyGallery.view.tsx to MyGallery.sidebar.tsx moves that same UI into the side panel. You do not need to change any code beyond the manifest key. A property editor is the exception, because its code reads and writes a property value through context.property. Moving it to another surface leaves it with no property to edit.
An entry file imports register() from @optimizely/cms-extensibility-sdk and calls it once, at the top level (not inside a component, not inside useEffect). Pass register() a callback that takes an ExtensionContext and returns the React element to render. The CMS host loads your built bundle inside an iframe, which triggers the register() call. The host then invokes your callback with a populated ExtensionContext and mounts the returned element as the extension's UI.
import {register, type ExtensionContext} from '@optimizely/cms-extensibility-sdk';
function MyExtension({context}: {context: ExtensionContext}) {
return <div>Hello from the sidebar.</div>;
}
register((context) => <MyExtension context={context} />);A view entry file is identical apart from the filename with identical register() call and ExtensionContext:
import {register, type ExtensionContext} from '@optimizely/cms-extensibility-sdk';
function MyGallery({context}: {context: ExtensionContext}) {
return <div>Hello from the full page.</div>;
}
register((context) => <MyGallery context={context} />);Do not export a default React component or expect anything from the bundle's module exports. The host only triggers the register() call and ignores everything else. Calling register() more than once per bundle is an error.
UI component library
Build extension UIs with the Optimizely Axiom React component library. Axiom is the design system Optimizely CMS itself is built with, so extensions that use it inherit the same typography, spacing, color tokens, and interactive components as the surrounding CMS UI. Editors see a panel that looks and behaves like a native part of the product, not an embedded third-party widget.
The sample app uses React 19 with Axiom and is the recommended starting point.
Extension context
The ExtensionContext passed to the factory exposes three API namespaces, extension, content, and property. See the SDK reference on npm for full type signatures. The following sections summarize the extension and content namespaces. See Property editors for the property namespace.
The extension namespace handles lifecycle and backend communication:
- setReady(): Promise<void> – Signals to the CMS that the extension has finished initializing. The CMS may show a loading indicator until this is called. Call it once after initial setup.
- getDefinition(): Promise<ExtensionDefinition> – Returns the extension's registered definition (id, displayName, type, optional iconUrl, optional configuration).
- invokeFunction(functionId, parameters?): Promise<FunctionInvocationResult> – Calls a backend function declared with accepts: cms_ui_extension. Returns {statusCode, data}. See Call the backend.
The content namespace reads and reacts to the content currently open in the CMS UI:
- get(): Promise<ContentState | null> – One-time snapshot of the currently active content.
- subscribe(callback): () => void – Subscribes to content changes. The callback fires whenever the active content changes and receives a ContentState | null. Returns an unsubscribe function — call it in your useEffect cleanup to avoid memory leaks.
Read the initial value with get(). Do not rely on subscribe() to deliver the current content when the extension mounts as it reports changes that happen after you subscribe. An extension that only subscribes displays nothing until the editor switches to a different content item.
ContentState carries the fields the CMS exposes about the active content item:
type ContentState = {
key?: string;
version?: string;
locale?: string;
publicUrl?: string; // version 1.1.0 or later
} | null;Use content.subscribe to react when the editor switches to a different content item, for example, to refresh data tied to the current page or to clear UI state from the previous selection.
The content namespace is meaningful in a sidebar extension, which mounts beside an open content item. In a view extension there is no active content item, so get() and subscribe() do not report one. Handle the null state as the normal case similar to the following example.
The following example reads the initial value with get(), then subscribes for later changes, and treats null as a valid state:
import {register, type ContentState, type ExtensionContext} from '@optimizely/cms-extensibility-sdk';
import {useEffect, useState} from 'react';
function MyExtension({context}: {context: ExtensionContext}) {
const [content, setContent] = useState<ContentState>(null);
useEffect(() => {
let active = true;
// Initial value — subscribe() only reports later changes.
void context.content.get().then((state) => {
if (active) setContent(state);
});
const unsubscribe = context.content.subscribe((state) => {
setContent(state);
// React to the change — for example, fetch related data or reset UI:
// if (state?.key) void loadDataFor(state.key);
});
void context.extension.setReady();
return () => {
active = false;
unsubscribe();
};
}, [context]);
if (!content?.key) return <p>No content selected.</p>;
return <p>Editing: {content.key} ({content.locale ?? 'default locale'})</p>;
}
register((context) => <MyExtension context={context} />);Property editors
A property-editor extension edits the value of one content property. It uses the property namespace of ExtensionContext:
- get(): Promise<PropertyState> – One-time snapshot of the bound property.
- set(value): Promise<PropertySetResult> – Writes a new value to the bound property in the CMS editor. Resolves to {success, error?}. Check success instead of assuming the write was accepted.
- subscribe(callback): () => void – Subscribes to changes of the bound property. The callback receives a PropertyState. Returns an unsubscribe function — call it in your useEffect cleanup.
PropertyState carries the value and how the editor may change it:
interface PropertyState {
value: unknown;
readonly: boolean;
settings?: Record<string, unknown>;
}- value – The current value. Pass set() a value that matches the property_type declared in app.yml.
- readonly – When true, the CMS does not let the editor change the property. Disable your inputs and do not call set().
- settings – Optional settings the CMS passes for the property.
As with content, read the initial value with get() and use subscribe() only for later changes. An editor that only subscribes renders empty until something else changes the property.
The following example edits a string property with a color input:
import {register, type ExtensionContext, type PropertyState} from '@optimizely/cms-extensibility-sdk';
import {useEffect, useState} from 'react';
function MyColorPicker({context}: {context: ExtensionContext}) {
const [state, setState] = useState<PropertyState | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let active = true;
// Initial value — subscribe() only reports later changes.
void context.property.get().then((next) => {
if (active) setState(next);
});
const unsubscribe = context.property.subscribe(setState);
void context.extension.setReady();
return () => {
active = false;
unsubscribe();
};
}, [context]);
async function save(value: string) {
const result = await context.property.set(value);
setError(result.success ? null : (result.error ?? 'The CMS rejected the value.'));
}
return (
<div>
<input
type="color"
disabled={!state || state.readonly}
value={typeof state?.value === 'string' ? state.value : '#000000'}
onChange={(event) => void save(event.target.value)}
/>
{error && <p>Error: {error}</p>}
</div>
);
}
register((context) => <MyColorPicker context={context} />);A property editor can call backend functions through context.extension.invokeFunction() like any other extension, for example, to search an external system and store the selected item's ID.
Call the backend
When the UI needs server-side work, declare a backend function with accepts: cms_ui_extension and invoke it from the frontend through context.extension.invokeFunction().
Declare the backend function
Add an entry under functions: in app.yml with accepts: cms_ui_extension:
functions:
cms_extension: # functionId — referenced from the UI
entry_point: CmsUiExtension # class name and source filename under src/backend/functions/
description: Backend proxy for the UI extensions.
accepts: cms_ui_extensionOne function can serve every extension in the app, whatever injection point each targets. A common pattern is to read an action field from the request body and dispatch to a handler, so a sidebar and a view extension share a single backend entry point.
Set accepts: cms_ui_extension only when the UI bundle invokes it through context.extension.invokeFunction(). Functions exposed as HTTP webhooks or used as form sources keep their existing definition and omit accepts. A single app can declare both kinds of functions in the same functions: section.
Functions with accepts: cms_ui_extension have two constraints:
- They cannot be global functions.
- They cannot define an installation_resolution strategy.
See Functions for the base function model.
The function reads input parameters from the request body (this.request.bodyJSON) and returns a JSON document through new App.Response(status, body). Modify the request and response shapes accordingly. The platform does not impose an envelope.
import * as App from '@zaiusinc/app-sdk';
export class CmsUiExtension extends App.Function {
public async perform(): Promise<App.Response> {
const {query, page = 1} = (this.request.bodyJSON ?? {}) as {query?: string; page?: number};
if (!query) {
return new App.Response(400, {error: 'missing_query'});
}
const results = await search(query, page);
return new App.Response(200, {results});
}
}Invoke from the frontend
Call the backend half through context.extension.invokeFunction(functionId, parameters?). The functionId is the key under functions: in app.yml (not the class name). parameters is serialized as the request body the backend receives in this.request.bodyJSON. The call resolves to {statusCode, data} — the HTTP status the backend returned and the parsed JSON body.
import {register, type ExtensionContext} from '@optimizely/cms-extensibility-sdk';
import {CMS_EXTENSION_FUNCTION_ID} from '@shared/constants';
import {useEffect, useState} from 'react';
interface SearchResponse {
results: Array<{id: string; title: string}>;
}
function MyExtension({context}: {context: ExtensionContext}) {
const [items, setItems] = useState<SearchResponse['results']>([]);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
void context.extension.setReady();
}, [context]);
async function runSearch(query: string) {
const response = await context.extension.invokeFunction(CMS_EXTENSION_FUNCTION_ID, {
query,
page: 1
});
if (response.statusCode !== 200) {
setError(`Request failed (${response.statusCode}).`);
return;
}
setError(null);
setItems((response.data as SearchResponse).results);
}
return (
<div>
<button onClick={() => void runSearch('beach')}>Search</button>
{error && <p>Error: {error}</p>}
<ul>{items.map((item) => <li key={item.id}>{item.title}</li>)}</ul>
</div>
);
}
register((context) => <MyExtension context={context} />);Keep request and response types in a src/shared/ module so both halves type-check against the same definitions. See Share code between the frontend and backend.
Notes on the call:
- Every invocation goes through the OCP platform. There is no direct browser-to-third-party-API path. The backend half is the only place that holds credentials.
- invokeFunction rejects (throws) only on transport errors. Application-level failures arrive as a non-2xx statusCode. Check before reading data.
- Errors thrown from the backend perform() method surface as statusCode: 500 with an empty body. Catch and convert errors inside perform() into a normal JSON response if you want the UI to read them.
Build considerations
The sample app and the CMS UI extension scaffold both ship with a working Vite setup — vite.backend.config.mjs for the backend half, vite.ui.config.mjs for the frontend half, and ocp-app.config.mjs registering the cmsUiExtensions() plugin. Start from one of these and you do not need to write any build configuration yourself.
Modify the build configuration (swap plugins, add transforms, and change asset handling) as long as the final dist/ layout matches what OCP expects:
- Everything under src/backend/ compiles to dist/, mirroring the source subtree. Each backend .ts/.tsx/.js/.jsx file becomes one CommonJS file at the matching path. For example, src/backend/functions/Foo.ts → dist/functions/Foo.js, src/backend/lifecycle/Lifecycle.ts → dist/lifecycle/Lifecycle.js, src/backend/lib/client.ts → dist/lib/client.js. The runtime resolves intra-bundle imports against this tree, so do not flatten or rename. Keep paths intact.
- dist/cms-ui-extensions/<entry_point>.js, one ESM bundle per UI entry, and a manifest.json mapping each source entry to its built file and referenced chunks/assets.
- dist/app.yml, dist/forms/, dist/assets/, copied from the project root.
Backend static resources (.yml, .json) co-located with code under src/backend/ are copied to the matching dist/ path.
ocp app validate checks for the bundle files declared in ui_extensions: and fails the build if any are missing.
Yarn node-modules linker
If you use Yarn 2 or later (Berry), the nodeLinker setting in .yarnrc.yml must be node-modules:
nodeLinker: node-modulesPlug'n'Play (the Yarn Berry default) is not supported. The CMS UI extension bundler and the OCP runtime both expect a flat node_modules/ tree on disk. The sample app and scaffold template already set this.
Share code between the frontend and backend
Both Vite configs and tsconfig.json in the sample app wire up a src/shared/ directory and a @shared/* path alias. Modules placed there can be imported from either half:
export const CMS_EXTENSION_FUNCTION_ID = 'cms_extension';
export const DEFAULT_PER_PAGE = 12;import {DEFAULT_PER_PAGE} from '@shared/constants';import {CMS_EXTENSION_FUNCTION_ID, DEFAULT_PER_PAGE} from '@shared/constants';Shared modules are compiled into both bundles, so they must work in both runtimes. The practical limits are as follows:
- No Node built-ins or node:* imports (fs, process, crypto, and so on) – These break the browser bundle.
- No browser globals (window, document, navigator) – These throw at backend startup.
- No platform-specific dependencies – Only packages that ship isomorphic builds (or pure TypeScript) belong in src/shared/.
- Treat shared code as public – Anything in src/shared/ ends up in the browser bundle. Do not place secrets, internal endpoints, or credentials there.
Constants, type-only declarations (interface, type), and pure utility functions are the typical contents. Use shared modules to keep the function-ID string, request and response shapes, and validation rules in sync between the two halves.
Installation scope
A CMS UI extension app uses the CMS installation scope. You configure the app separately for each CMS instance. A CMS-scoped app is installed once per CMS instance, not once at the OCP account level.
Understand how an app is classified
OCP classifies an app as CMS-scoped the first time you publish a version that declares ui_extensions:. If the app already has OCP account-level installations when you publish, OCP does not classify it as CMS-scoped, and it stays installed at the account level.
The classification is set once and applies to every later version of the app.
Understand what changes for a CMS-scoped app
A CMS-scoped app differs from an account-scoped app in the following ways:
- One installation per CMS instance – Each installation targets a single CMS instance, identified by its instance ID (the scope). Installing the app into another CMS instance creates a separate installation.
- Separate configuration – Settings and storage belong to an installation, so each CMS instance is configured independently. Credentials entered for one instance do not apply to another.
- Scope instead of tracker ID in the CLI – OCP CLI commands identify an installation with --scope INSTANCE_ID instead of a tracker ID because each installation belongs to a CMS instance. See the OCP CLI command reference for details.
- No data sync or Agent Platform tool features – A CMS-scoped app cannot declare data sync or Agent Platform tool features. The build fails if a version adds them.
Settings form
CMS UI extension apps use the standard forms/settings.yml file and onSettingsForm lifecycle hook for per-install configuration (for example, credentials for the upstream API). The backend function reads from storage.settings when handling UI requests. See Settings form and Lifecycle hooks.
Local testing
The OCP local testing tool (ocp dev) supports CMS UI extensions. After running yarn build, start the tool from your app directory and use the CMS UI Extensions section in the web interface to render your built bundles in a simulated CMS host. The simulated host wires context.extension.invokeFunction() calls to your local backend functions, so the full UI-to-function round trip runs without publishing. The simulated host renders sidebar and view extensions. It does not yet render property-editor extensions, so test those in a CMS instance after you publish. See Local testing for setup and feature coverage.
Validation
Run ocp app validate (or npx ocp-app-sdk validate) to check the manifest and bundles. The cmsUiExtensions() plugin contributes validators that check for:
- Duplicate name values across ui_extensions: entries
- Duplicate entry_point values across ui_extensions: entries
- Blank display_name values
- A missing metadata.property_type on a property-editor entry
- A property_type and property_format combination that is not in the supported list
- A built bundle file at dist/cms-ui-extensions/<entry_point>.js for every declared entry
For example, a property-editor entry that declares property_type: date fails with the following error:
Invalid app.yml: ui_extensions.property-editor.my-date-picker metadata.property_type 'date' is not supported. Must be one of: string, richtext, integer, float, boolean