Developer Demo

CMS UI Extensions

Add your own panels to the Optimizely SaaS CMS editor. An extension is a CMS-scoped OCP app: a React bundle the CMS renders in an iframe, plus a backend function on OCP that holds the credentials and talks to other APIs.
BetaSidebar, view, property editorBackend runs on OCPReact + Axiom

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 pointWhere it appearsBound toContext it reads
sidebarA panel beside the content editor, opened from a tab on the editor's right-hand railThe item open in the editorcontent.get() / content.subscribe()
viewA full-width page, opened from Extensions in the CMS navigation railNothing - instance-wideinvokeFunction() only
property-editorReplaces the built-in editor for a single propertyOne property of the itemproperty.get() / set() / subscribe() (SDK 1.1.0+)

Where extensions appear in the editor

view
Content editorproperty-editor replaces one field here
sidebar panelYour bundle in an iframe, bound to the open item
Ex

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.

CallReturnsNotes
extension.setReady()voidTells 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.
No variation, no content type and no CMS token. The context names the item and its version, nothing more. If the extension needs the item's variations, type or properties, the backend reads them from the CMS REST API with its own API client - for example 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.

1

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.

app.yml
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: Experiments
app.yml - a property editor
ui_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
// 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()] });
2

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.

FxExperiment.sidebar.tsx
// 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>
));
3

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.

CmsUiExtension.ts
// 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" });
    }
  }
}
4

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
# 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: primary
5

Test 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.

terminal
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.
6

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.

terminal
# 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)'
7

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.

terminal
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> --tail

Iframe 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.

SymptomCauseFix
No sidebar tab and no Extensions button - not even for Optimizely's own sample appThe 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 installCLI older than 2.0.15.ocp update
Preview track [dev/<user>] was not foundInstalling 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 expectA 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 oneInstalls are pinned to a version.ocp directory upgrade app --scope <uuid> -v <version> once the new version reports PUBLISHED.
ocp app logs is emptyCMS-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.

getStatuscreateExperimentsyncVariationssetMetricssetRunningsetRoutelinkExistingunlinklistEventscreateEventlistExperiments
The CMS API cannot create variations. So the direction is one way: create the variation in the Variations dropdown, named exactly as the FX variation key, and the sidebar mirrors it to FX. The variation name rules, the __v_ URL segment and the control impression are covered on the Feature Experimentation demo.