Plugin blocks and routes
Plugins extend the UI by contributing React blocks into named slots, and by mounting their own admin and storefront routes. Persistent plugin state goes through namespaced PluginData, not custom Prisma models.
Storefront slots
Section titled “Storefront slots”Plugins inject UI into named slots in the active theme. Each slot is a well-known insertion point that the theme renders at a specific location on the page.
| Slot name | Location |
|---|---|
home.hero |
Home page — inside the hero section |
home.featured |
Home page — featured products area |
product.afterDescription |
Product page — below the product description |
product.sidebar |
Product page — sidebar area |
category.top |
Category listing page — above the product grid |
cart.summary |
Cart page — order summary area |
checkout.afterPayment |
Checkout flow — below the payment form |
account.dashboard |
Customer account dashboard |
layout.header |
Global layout — inside the header |
layout.footer |
Global layout — inside the footer |
These names match theme SLOT_NAMES. Themes fetch blocks with getSlotBlocks / getSlotBlocksMap and render them with SlotBlocks. See theme slots.
Contributing a storefront block
Section titled “Contributing a storefront block”Runtime only reads manifest.blocks (components imported and listed in index.server.js). Files under blocks/ are not auto-discovered — omitting a block from the manifest means it never renders, even if the file exists.
Create a .jsx file under your plugin’s blocks/ directory using lowercase, hyphenated paths. Mirror each dotted segment of the slot name as its own directory segment, then use a hyphenated leaf file (for example slot product.afterDescription → blocks/product/after-description.jsx):
app/plugins/my-plugin/blocks/product/after-description.jsxRegister it explicitly in index.server.js:
import ProductAfterDescriptionBlock from './blocks/product/after-description';
export const pluginManifest = definePlugin({ blocks: { 'product.afterDescription': ProductAfterDescriptionBlock, },});The file must have a default export that is a React component. The component receives slot-specific props (for example product on product page slots):
export default function ProductAfterDescriptionBlock({ product }) { if (!product) return null; return <div>{/* rendered below the product description */}</div>;}The theme renders slot blocks via getSlotBlocks(slotName) from app/core/themes/index.server.js. Blocks only render when the plugin is enabled; order follows pluginOrder ∩ enabledPlugins.
Admin slots
Section titled “Admin slots”Plugins can inject UI into named slots in admin views. Admin and storefront slots share the same manifest blocks map — use distinct slot names for each surface.
| Slot name | Location |
|---|---|
dashboard.widgets |
Admin dashboard — below KPI tiles |
order.detail |
Order detail page — below the page header |
customer.detail |
Customer detail page — below the page header |
product.editor |
Product editor — below the page header |
The canonical list lives in ADMIN_SLOT_NAMES in app/core/admin/slots/index.server.js.
Same contribution pattern as storefront: put the component under blocks/, then register it in manifest.blocks from index.server.js. For example, slot dashboard.widgets → blocks/dashboard/widgets.jsx.
import DashboardWidgetsBlock from './blocks/dashboard/widgets';
export const pluginManifest = definePlugin({ blocks: { 'dashboard.widgets': DashboardWidgetsBlock, },});Admin route loaders resolve blocks server-side with getAdminSlotBlocksMap() from app/core/admin/slots/index.server.js and pass them to the shared SlotBlocks component in app/components/slot-blocks/index.jsx.
Admin slot props
Section titled “Admin slot props”Each admin page passes context to plugin blocks via slotProps:
| Admin page | slotProps |
|---|---|
| Dashboard | { totalOrders, totalRevenueCents, abandonedCheckouts, lowStockCount, recentOrders } |
| Order detail | { order } |
| Customer detail | { customer } |
| Product editor | { product, mode } (mode is 'create' or 'edit') |
Blocks only render when the plugin is enabled. Render order follows pluginOrder ∩ enabledPlugins (same as storefront slots and i18n catalog merge).
Admin routes
Section titled “Admin routes”A plugin that includes admin/routes/index.server.js and admin/routes.client.js gets a dedicated admin page mounted at /admin/plugins/<slug>/*. The slug segment is bermooda.slug, not the full package id.
Admin routes are defined as a server/client pair:
admin/routes/index.server.js— exports route descriptors with optionalloaderandactionadmin/routes.client.js— exports route descriptors with the clientComponent
Both files must export the same routes array shape. Each route entry follows React Router conventions:
{ path: '', // string — path relative to /admin/plugins/<slug>/ loader: async () => { /* return data */ }, // optional action: async ({ request, params }) => { /* mutate */ }, // optional Component: MyComponent, // required — React component}Paths may use :param segments and a trailing * splat (captured as params.splat). Exact paths win over patterns; otherwise the first registered matching pattern wins. Matched params are merged into React Router params when the dispatcher calls loader / action.
Example server routes file (admin/routes/index.server.js):
import { readPluginData, writePluginData,} from '#/core/plugins/data/index.server';
const PLUGIN_ID = '@acme/my-plugin';
export const routes = [ { path: '', async loader() { const items = await readPluginData(PLUGIN_ID, 'items', []); return { items }; }, async action({ request }) { const formData = await request.formData(); const items = await readPluginData(PLUGIN_ID, 'items', []); await writePluginData(PLUGIN_ID, 'items', [ String(formData.get('label') ?? ''), ...items, ]); return { ok: true }; }, Component: MyAdminPage, },];
function MyAdminPage({ loaderData }) { const { items } = loaderData ?? { items: [] }; return <div>{/* render items */}</div>;}The loader function runs on the server before the component renders. Components receive loader data via the loaderData prop. The dispatcher action resolves the same route descriptor and invokes descriptor.action when present; otherwise it responds with 405 Method Not Allowed.
The dispatcher resolves admin pages with resolvePluginAdminRoute(pluginSlug, params['*']). Route presence is glob-driven; do not add adminRoutes to package metadata.
Prefer #/core/plugins/data/index.server (readPluginData / writePluginData) over importing Prisma. Oxlint blocks #/libs/prisma* inside app/plugins/**.
Storefront routes
Section titled “Storefront routes”A plugin that includes storefront/routes/index.server.js and storefront/routes.client.js gets a dedicated storefront page mounted at /apps/<slug>/*. The slug segment is bermooda.slug.
Storefront routes follow the same split-module pattern as admin routes:
storefront/routes/index.server.js— exports route descriptors with optionalloaderandactionstorefront/routes.client.js— exports route descriptors with the clientComponent
Each route entry has the same shape, with path relative to /apps/<slug>/. Paths may use :param segments and a trailing * splat. Exact paths win over patterns; otherwise the first registered matching pattern wins.
The storefront dispatcher:
- uses
params['*']as the splat path - requires the plugin to be registered and enabled (loader and action)
- resolves the server descriptor with
resolvePluginStorefrontRoute(pluginSlug, params['*']) - runs the descriptor
loaderon the server when present - runs the descriptor
actionon POST/mutations when present; otherwise405 Method Not Allowed - resolves the client component from
storefront/routes.client.js - wraps the rendered page in the active theme
Layoutwhen available
That Layout wrap is the exception described in theme layout ownership: plugin storefront pages are not theme pages, so the dispatcher supplies chrome.
Example client routes file (storefront/routes.client.js):
import { AnalyticsPage } from './analytics-page';
export const routes = [{ path: '', Component: AnalyticsPage }];Example URL:
/apps/my-plugin/Plugin data storage
Section titled “Plugin data storage”Plugins have a namespaced key-value store backed by the PluginData database table.
The table schema has a composite unique constraint on (pluginId, key). Two different plugins can use the same key name without conflict, and reads/writes for one plugin can never touch another plugin’s data. pluginId is the full package name.
Values are stored as JSON strings. ctx.plugin.set() serializes with JSON.stringify before writing, and ctx.plugin.get() parses with JSON.parse on read. If parsing fails (for example if the stored value is a raw string), get() returns the raw string value.
// Store a structured objectawait ctx.plugin.set('config', { apiKey: 'abc123', endpoint: 'https://...' });
// Read it back — returns the parsed objectconst config = await ctx.plugin.get('config');
// Append to a list (common pattern for event log plugins)const existing = (await ctx.plugin.get('events')) ?? [];const updated = [newEvent, ...existing].slice(0, 100);await ctx.plugin.set('events', updated);
// Remove a keyawait ctx.plugin.delete('config');From route loaders and other modules that do not receive ctx, use readPluginData(pluginId, key, fallback) and writePluginData(pluginId, key, value) from #/core/plugins/data/index.server.