Building the bundle
A plugin's admin UI compiles to a single ES module the host imports at runtime.
plugins/Article/ui/index.tsx → public/plugins/article/plugin.js
bundle() returns that path automatically when the file exists, so nothing needs declaring.
Config#
vite.plugin.config.js at the project root builds any plugin:
export default defineConfig({
esbuild: {
// JSX compiles to the HOST's React, reached through the global.
jsxFactory: 'window.AdminSDK.React.createElement',
jsxFragment: 'window.AdminSDK.React.Fragment',
},
build: {
lib: {
entry: 'plugins/Article/ui/index.tsx',
formats: ['es'], // ES module: the host uses dynamic import()
fileName: () => 'plugin.js',
},
outDir: 'public/plugins/article',
rollupOptions: { external: [] },
},
});
yarn build:plugin article
Why JSX compiles to a global#
jsxFactory is the mechanism that keeps React out of the bundle. With the default factory,
every JSX element would reference an imported React, Rollup would bundle it, and the page
would run two React copies.
Two copies is not a size problem — it is a correctness problem:
- hooks throw
Invalid hook call - context from the host is invisible to plugin components
instanceofchecks across the boundary fail- error messages point at React internals, nowhere near the actual cause
Compiling JSX to window.AdminSDK.React.createElement means the bundle has no React import
to begin with.
What may never be bundled#
| Never bundle | Use instead |
|---|---|
react, react-dom |
SDK.React, SDK.ReactDOM |
| Any library that imports React | The host equivalent under SDK.ui / SDK.components |
@dnd-kit/* |
SDK.dnd |
| Router | SDK.router |
| Redux / store toolkit | SDK.store |
clsx / tailwind-merge |
SDK.utils.cn |
lucide-react |
SDK.components.DynamicIcon |
The rule is not "avoid large dependencies". It is avoid anything that imports React, at any depth. A small library that pulls React transitively breaks the admin exactly as badly as React itself.
Libraries with no React dependency — a date parser, a validation library — are fine to bundle.
Loading#
The host discovers bundles from the plugin list API and imports them dynamically:
GET /api/admin/plugins → [{ key: 'article', bundle: '/plugins/article/plugin.js' }]
const mod = await import(/* @vite-ignore */ bundle);
The module registers itself as a side effect of loading; the host does not read its exports.
A bundle that throws during evaluation registers nothing, and its screens 404 in the SPA — so
keep the top level of index.tsx free of work that can fail.
Checklist#
- Entry at
plugins/{Name}/ui/index.tsx - Output to
public/plugins/{key}/plugin.js -
formats: ['es'] -
jsxFactorypointing at the global - No React import anywhere in the plugin source
-
requiresSdk()set to the SDK version whose surface you use - Routes also declared in
adminRoutes()server-side