Developer Demo
CMS UI Extensions
What an extension is #
SaaS CMS runs no code of yours, so an extension lives somewhere else: an OCP app with runtime node22-cms-ext. Its UI bundle runs in the editor's browser inside an iframe, and its backend function runs on OCP. The two only talk through invokeFunction - API keys never reach the browser and there is no CORS to set up.
One request from the sidebar
Editor opens a page
CMS knows key + version
Sidebar iframe
your React bundle
invokeFunction
{ action, params }
OCP function
node22-cms-ext
Your APIs
FX, CMS REST, anything
| Injection point | Where it appears | Bound to | Context it reads |
|---|---|---|---|
| sidebar | A panel beside the content editor, opened from a tab on the editor's right-hand rail | The item open in the editor | content.get() / content.subscribe() |
| view | A full-width page, opened from Extensions in the CMS navigation rail | Nothing - instance-wide | invokeFunction() only |
| property-editor | Replaces the built-in editor for a single property | One property of the item | property.get() / set() / subscribe() (SDK 1.1.0+) |
Where extensions appear in the editor
A sidebar gets a tab on the editor's right-hand rail and opens beside the content. A view is reached from the Extensions button in the navigation rail. A property editor takes the place of one field's built-in input.
What the extension can see #
register(factory) from @optimizely/cms-extensibility-sdk mounts your React tree and hands it an ExtensionContext. That context is the whole contract with the CMS - small on purpose.
| Call | Returns | Notes |
|---|---|---|
| extension.setReady() | void | Tells the CMS the panel finished loading, so it drops its loading state. |
| extension.getDefinition() | { id, displayName, type, configuration } | The extension's own registration. |
| extension.invokeFunction(id, payload) | { statusCode, data } | Calls your OCP function. The only way to reach a backend - the function has no public URL. |
| content.get() / content.subscribe(cb) | { key, version, locale, publicUrl } | The item open in the editor. Fires again when the editor switches item, locale or version. |
| property.get() / set(v) / subscribe(cb) | { value, readonly, settings } | property-editor only. set() writes the bound property in the editor. |
GET /v1/content/{key}/locales/{locale} lists every version, and named variations carry a variation field. The editor's Variations dropdown and toolbar cannot be extended.Setting one up #
Start from Optimizely's sample, unsplash-viewer-cms-ui-extension-ocp-app, and keep its Vite and OCP configs verbatim - they encode the bundle layout OCP expects. You need an OCP developer account, OCP CLI 2.0.15 or later, and a CMS instance with UI extensions enabled.
Declare the app and its extensions
An app with ui_extensions is CMS-scoped: one install per CMS instance. That rules out data sync sources and Opal tools, and its functions cannot be global or use installation_resolution. Each entry_point must build to dist/cms-ui-extensions/<entry_point>.js.
meta:
app_id: fx_cms_experiments
display_name: FX Experiments
version: 0.0.1-dev.0
vendor: optimizely
summary: Turn a page's CMS variations into an FX A/B test from the editor.
support_url: https://www.optimizely.com
contact_email: you@example.com
categories:
- Testing & Utilities
availability:
- all
runtime: node22-cms-ext # the CMS UI extension runtime
functions:
cms_extension:
entry_point: CmsUiExtension
accepts: cms_ui_extension # callable only through invokeFunction, never by URL
ui_extensions:
sidebar:
- name: fx-experiment
entry_point: FxExperiment # build must emit dist/cms-ui-extensions/FxExperiment.js
display_name: Experiment
view:
- name: fx-experiments
entry_point: FxExperiments
display_name: Experimentsui_extensions:
property-editor:
- name: brand-color
entry_point: BrandColor
display_name: Brand color
metadata:
property_type: string # string | richtext | integer | float | boolean
property_format: shortstring # optional refinement// ocp-app.config.mjs - adds the ui_extensions manifest schema and the bundle validator
import { defineConfig } from "@zaiusinc/app-sdk";
import { cmsUiExtensions } from "@optimizely/ocp-cms-ui-extensions-sdk";
export default defineConfig({ plugins: [cmsUiExtensions()] });Write the UI bundle
Any framework works; the sample uses React 19 with @optiaxiom/react, Optimizely's Axiom design system, so the panel looks native. Call setReady() once mounted and always return the subscribe unsubscribe from your effect.
// src/cms-ui-extensions/fx/FxExperiment.sidebar.tsx
// The <Name>.<injection-point>.tsx suffix is how the Vite config finds entries.
import { AxiomProvider, Button, Text } from "@optiaxiom/react";
import { register, type ContentState, type ExtensionContext } from "@optimizely/cms-extensibility-sdk";
import { useEffect, useState } from "react";
function FxExperiment({ context }: { context: ExtensionContext }) {
const [content, setContent] = useState<ContentState>(null);
const [status, setStatus] = useState<unknown>(null);
useEffect(() => {
void context.extension.setReady();
void context.content.get().then(setContent);
return context.content.subscribe(setContent); // returns its own unsubscribe
}, [context]);
async function load() {
const res = await context.extension.invokeFunction("cms_extension", {
action: "getStatus",
params: { content },
});
setStatus(res.data); // whatever the OCP function returned
}
return content?.key ? <Button onClick={load}>Load experiment</Button> : <Text>Open a page</Text>;
}
register((context) => (
<AxiomProvider>
<FxExperiment context={context} />
</AxiomProvider>
));Route actions in one backend function
One function, an { action, params } body, and an { ok, result } | { ok: false, error } envelope keeps every surface on the same backend. Credentials come from the per-install settings store, never from the browser.
// src/backend/functions/CmsUiExtension.ts - one function, routed by action
import * as App from "@zaiusinc/app-sdk";
import { storage } from "@zaiusinc/app-sdk";
export class CmsUiExtension extends App.Function {
public async perform(): Promise<App.Response> {
const { action, params } = this.request.bodyJSON ?? {};
// Per-install values saved through forms/settings.yml
const fx = await storage.settings.get<{ apiToken?: string; projectId?: string }>("fx");
if (!fx.apiToken) return new App.Response(412, { ok: false, error: "not_configured" });
switch (action) {
case "getStatus":
return new App.Response(200, { ok: true, result: await getStatus(fx, params) });
default:
return new App.Response(400, { ok: false, error: "unknown_action" });
}
}
}Add a settings form
OCP renders forms/settings.yml as the app's settings screen and calls Lifecycle.onSettingsForm on save - validate the credentials against the real APIs there and only then storage.settings.put them. A secret field comes back blank unless the editor retypes it, so keep the stored value when it is empty.
# forms/settings.yml - OCP renders this as the app's settings screen
sections:
- key: fx
label: Feature Experimentation
elements:
- type: secret
key: apiToken
label: Personal access token
help: Leave blank to keep the saved one. # every input needs help, or validate fails
- type: text
key: projectId
label: Project ID
help: The FX project the site's SDK key belongs to.
- type: button
label: Save
action: saveFx # -> Lifecycle.onSettingsForm("fx", "saveFx", formData)
style: primaryTest locally against a mock CMS
ocp dev renders sidebar and view bundles inside a simulated CMS and runs your backend locally. Its Extension dev tools panel sets the ContentState - key, version, locale, publicUrl - and broadcasts it to the sidebar, so you can point the panel at a real content key. Property editors can only be tested after publishing.
yarn validate # lint + typecheck + build + ocp-app-sdk validate
ocp dev -p 3100 # 3000 is usually taken by next dev
# Mock CMS at http://localhost:3100 renders the sidebar and view bundles and routes
# invokeFunction to your local backend. Settings are read from .ocp-local/settings.json -
# keep .ocp-local/ in .gitignore, it holds your secrets.Find the CMS instance UUID
Installs are scoped to a CMS instance UUID. ocp accounts whoami lists every instance your login can reach - including other organizations' - so do not pick one by name. The instance's own API token carries an instanceId claim.
# The CMS API token names its own instance - no guessing from a list of names
curl -s -X POST https://api.cms.optimizely.com/oauth/token \
-H "Content-Type: application/json" \
-d "{\"grant_type\":\"client_credentials\",\"client_id\":\"$CLIENT_ID\",\"client_secret\":\"$CLIENT_SECRET\"}" \
| node -e 'const t = JSON.parse(require("fs").readFileSync(0)).access_token;
const c = JSON.parse(Buffer.from(t.split(".")[1], "base64url"));
console.log(c.instanceId, c.siteUrl)'Publish, install and configure
prepare uploads the source and builds it on OCP; the UI bundles are hosted by the platform, not by your app. Install only once the version reports PUBLISHED, then fill in the settings form for that install in the OCP App Directory and reload the CMS.
ocp update # --scope needs CLI 2.0.15+
ocp app register --appId fx_cms_experiments --name "FX Experiments" --product HUB --personal
ocp app prepare --bump-dev-version --publish # build on OCP and publish a dev version
ocp directory status fx_cms_experiments@0.0.1-dev.1 # wait for PUBLISHED
ocp directory install fx_cms_experiments@0.0.1-dev.1 --scope <cms-instance-uuid>
# Every later version
ocp directory upgrade fx_cms_experiments --scope <cms-instance-uuid> -v 0.0.1-dev.2
ocp app logs --appId fx_cms_experiments --scope <cms-instance-uuid> --tailIframe and build gotchas #
Popups and the clipboard can be blocked
The editor iframe may refuse window.open and the Clipboard API. Check what window.open returns and fall back to showing the URL with a copy button that uses execCommand.
Inline the fonts
Relative font URLs 404 from the CMS iframe. The sample's Vite config inlines web fonts as data URIs and injects CSS from JS, so each bundle is self-contained.
Yarn Berry needs node-modules
Plug'n'Play is not supported: set nodeLinker: node-modules in .yarnrc.yml. Yarn classic works as is.
Shared code stays neutral
src/shared is compiled into both the browser and the Node bundle, so it can use neither Node built-ins nor browser globals. Put the action contract and pure helpers there.
When nothing shows up #
Installed is not the same as visible. Work down this list before changing code - the first row is the most common, and no amount of redeploying fixes it.
| Symptom | Cause | Fix |
|---|---|---|
| No sidebar tab and no Extensions button - not even for Optimizely's own sample app | The CMS instance does not have UI extensions enabled (beta). Installing an app does not turn them on. | Ask Optimizely to enable CMS UI extensions for the instance UUID. Prove it first with the official unsplash_viewer sample, so you are not debugging your own app. |
| Unknown flag --scope on ocp directory install | CLI older than 2.0.15. | ocp update |
| Preview track [dev/<user>] was not found | Installing without a version follows a preview track, and none exists yet. | Install a pinned version: app@0.0.1-dev.2. |
| The install landed in an OCP account you did not expect | A CMS-scoped install goes into the OCP account that manages that CMS instance, not into your own OCP account. | Pass the instance UUID with --scope and let OCP resolve the account. Your personal OCP account may manage no CMS instance at all. |
| A new dev version is published but the CMS still runs the old one | Installs are pinned to a version. | ocp directory upgrade app --scope <uuid> -v <version> once the new version reports PUBLISHED. |
| ocp app logs is empty | CMS-scoped apps need --scope, and a function only logs what you log. | ocp app logs --appId app --scope <uuid> --tail, and log each action you route. |
Example: FX Experiments #
The extension built for this site, in the sibling repo fx-experiments-cms-extension. An Experiment sidebar turns the open page's CMS variations into a Feature Experimentation A/B test - start, pause, add variations later, edit metrics and jump to results - and an Experiments view lists every page under test.
Create experiment
CMS variations
made in the dropdown
FX flag
cms_flag + cms_route
Variations
control + one per CMS variation
A/B rule
traffic, audience, metrics
Results
fetch_results_ui_url
CMS variations in, FX experiment out
The sidebar reads the open page's variations through the CMS REST API, then creates the FX flag, one FX variation per CMS variation plus control, and an A/B rule with traffic, audience and metrics.
Variable defaults, not per-variation values
cms_flag and cms_route are set as flag variable defaults, so a variation added later in the FX UI is still picked up by the site's middleware.
control is the original page
No CMS variation is named control, so Graph serves the base item - and the catch-all still fires the FX impression for it, so the baseline arm is counted.
Results link comes from FX
The rule's fetch_results_ui_url, which FX only creates the first time the experiment starts.
Backend actions
Every action re-reads the page from the CMS and the rule from FX before answering, so the panel never shows stale state.
__v_ URL segment and the control impression are covered on the Feature Experimentation demo.