Integrate a theme with plugins
Themes and plugins do not import each other. A plugin advertises a capability, the render payload reports which capabilities are active, and the theme checks them through ctx.hasCapability().
Step 1: Define the public contract
Section titled “Step 1: Define the public contract”Choose a stable, namespaced capability that describes the feature rather than a specific implementation.
Good examples:
reviews.summarycommerce.productssearch.facets
Avoid names tied to an installation or version, such as acme-plugin-v2-installed.
Also decide where the public data will come from:
- Use block props when the data is part of the saved page document.
- Use a field in the platform render payload when the data must be generated dynamically for every request. Adding such a field requires a Z-CMS core contract change.
- Do not make the theme call a private plugin or CMS endpoint.
Step 2: Advertise the capability from the plugin
Section titled “Step 2: Advertise the capability from the plugin”Add the capability to the plugin’s plugin.json:
{ "id": "com.acme.plugin.reviews", "capabilities": ["reviews.summary"]}Only an installed and active plugin contributes capabilities to the site’s render payload. Deactivating the plugin removes them.
Step 3: Declare the capability as optional in the theme
Section titled “Step 3: Declare the capability as optional in the theme”Add the capability to theme.json:
{ "id": "com.acme.theme.storefront", "optionalCapabilities": ["reviews.summary"]}It is optional by design. A theme must still render when the plugin is absent, inactive, or replaced.
Step 4: Detect the feature in a template
Section titled “Step 4: Detect the feature in a template”Use ctx.hasCapability() and provide a fallback.
function ProductPage({ ctx, content }: PageTemplateProps<Settings>) { const reviewsEnabled = ctx.hasCapability("reviews.summary");
return ( <article> <h1>{content.title}</h1> {ctx.renderBlocks(content.blocks)} {!reviewsEnabled ? <p>Reviews are not available.</p> : null} </article> );}Capability detection answers only “is a provider active?”. It does not return reviews or other plugin records.
Step 5: Render a plugin-related block
Section titled “Step 5: Render a plugin-related block”If the page document contains a namespaced block with public data in its props, register its renderer in the theme.
Example block stored in content.blocks:
{ "id": "review-summary-1", "type": "acme/review-summary", "props": { "average": 4.8, "count": 124 }}Theme component:
interface ReviewSummaryProps { average?: unknown; count?: unknown;}
function ReviewSummary({ ctx, props,}: BlockProps<ReviewSummaryProps, Settings>) { if (!ctx.hasCapability("reviews.summary")) return null;
const average = typeof props.average === "number" ? props.average : 0; const count = typeof props.count === "number" ? props.count : 0;
return ( <section aria-label="Reviews"> <strong>{average.toFixed(1)} / 5</strong> <span>{count} reviews</span> </section> );}
const blocks = { "core/hero": Hero, "core/richtext": RichText, "acme/review-summary": ReviewSummary,};Validate plugin-related block props exactly as you validate core block props. They are JSON values and may be missing or come from an older plugin version.
Step 6: Handle missing and changed plugins
Section titled “Step 6: Handle missing and changed plugins”Test all of these states:
- The plugin is not installed.
- The plugin is installed but inactive.
- The plugin is active and the expected block data is present.
- The capability exists but the block props are empty or from an older schema.
- The page contains the block but the plugin no longer provides the capability.
The page must remain usable in every state. Do not make navigation, the page title, or the primary content depend entirely on an optional capability.
Current contract limits
Section titled “Current contract limits”The Theme SDK now provides a shared contract for indirect plugin integration:
ctx.hasCapability() detects a feature, ctx.getIntegration() reads the public
projection allow-listed by core, and ctx.renderSlot() positions interactive UI
owned by the runtime. A theme neither imports the plugin nor receives its
credentials.
The current contract still does not allow a theme to:
- invoke an arbitrary plugin handler directly (there is no
ctx.callPlugin()orctx.callCapability()); - read plugin settings or plugin storage;
- query plugin-owned database tables;
- fetch arbitrary plugin data through
ctx.hasCapability(); - register a new dynamic payload shape from a package alone.
If a feature needs request-time data or actions, core must define and allow-list
the corresponding public projection or integration action. For example,
ai.assistant currently exposes display data through getIntegration(), mounts
chat UI through renderSlot("floating"), and allows only the chat action through
the gateway. This is not a generic proxy through which a theme can call any
plugin. Directing a theme to a private API would still produce an unsupported and
unsafe theme.
See Render pages and blog posts for the complete template data flow.