Dev Guide
Dev GuideUser GuidesGitHubDev CommunityOptimizely AcademySubmit a ticketLog In
Dev Guide

CMS UI extensions

Build OCP apps that contribute UI panels into Optimizely CMS, backed by a function on the OCP platform.

📘

Beta

The following feature is in beta.

A CMS UI extension is an OCP app that contributes UI into Optimizely CMS.

OCP supports the following surfaces:

  • Content editor side panel – Declared with the sidebar injection point in app.yml. Mounts a panel next to the content editor.

Additional panel types and custom field editors are planned for future releases. The ui_extensions: schema is designed so each new surface lands as another injection point alongside sidebar.

Architecture

Unlike other OCP app types, a CMS UI extension app has code that runs in two separate runtimes:

HalfWhere it runsSource locationBuild outputTalks to
Frontend (UI bundle)The end user's browser, inside an iframe hosted by Optimizely CMSsrc/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 runtimesrc/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).

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, Opal 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).

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, and missing bundle files in dist/cms-ui-extensions/.

This SDK is never imported from frontend code.

Sample app

The Unsplash Viewer sample app demonstrates the full extension surface, including the sidebar UI, backend proxy, settings form, and credential validation. Fork it as a starting point.

github.com/optimizely/unsplash-viewer-cms-ui-extension-ocp-app

Manifest

runtime: node22-cms-ext

functions:
  cms_extension:
    entry_point: CmsUiExtension
    description: Backend proxy for the sidebar extension.
    accepts: cms_ui_extension

ui_extensions:
  sidebar:
    - name: my-extension
      entry_point: MyExtension
      display_name: My Extension

The ui_extensions: section maps each injection point to a list of UI entries. Only sidebar (the content editor side panel) is currently supported. Additional panel types and custom field editors are planned. 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.

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: (currently, only sidebar).

For the preceding manifest, the entry file is src/cms-ui-extensions/MyExtension.sidebar.tsx. Files outside this directory or named differently are ignored by the build — they do not appear in the output bundle, and ocp app validate reports a missing bundle.

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} />);

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 two API namespaces, extension and content. See the SDK reference on npm for full type signatures. The following sections summarize both namespaces.

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.

ContentState carries the fields the CMS exposes about the active content item:

type ContentState = {
  key?: string;
  version?: string;
  locale?: string;
} | 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.

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(() => {
    void context.extension.setReady();
    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);
    });
    return 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} />);

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 sidebar extension.
    accepts: cms_ui_extension

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.tsdist/functions/Foo.js, src/backend/lifecycle/Lifecycle.tsdist/lifecycle/Lifecycle.js, src/backend/lib/client.tsdist/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-modules

Plug'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.

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. 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 built bundle file at dist/cms-ui-extensions/<entry_point>.js for every declared entry