Cogeze
Admin UI

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:

js
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: [] },
  },
});
bash
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
  • instanceof checks 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' }]
ts
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']
  • jsxFactory pointing 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