Skip to main content

Building an external plugin bundle

External plugins ship as a .zip containing a pre-built ESM bundle, get uploaded into Flexweg via the Install plugin UI, and load at runtime via dynamic import() — no admin rebuild required.

This is the way to distribute plugins when you don't maintain your own admin fork.

Reference example

examples/external-plugin/ is a complete minimal plugin — clone it, rename it, customise it. The build config + ZIP packaging are already set up.

Project structure

my-external-plugin/
├── package.json
├── tsconfig.json
├── vite.config.ts
├── src/
│ ├── manifest.ts — default-exports PluginManifest
│ ├── (hooks, helpers, components)
│ └── types/
│ └── cms-runtime.d.ts — local types for @flexweg/cms-runtime
├── scripts/
│ └── pack-zip.mjs — bundles dist/ + manifest.json
└── manifest.json — installation metadata (NOT the PluginManifest)

Same two-manifest pattern as external themes:

  • manifest.json at the project root — installation metadata read by the admin's install flow (id, version, apiVersion, entry path)
  • src/manifest.ts — the actual PluginManifest object the admin registers at runtime

manifest.json (installation metadata)

{
"id": "my-plugin",
"name": "My Plugin",
"version": "1.0.0",
"apiVersion": "1.0.0",
"entry": "bundle.js",
"description": "Adds a 'Powered by my brand' tag to every page footer."
}

Same fields as the external theme version. See External theme bundle → manifest.json for field-by-field details.

Vite config

The bundle MUST be a single ESM file with React + family + @flexweg/cms-runtime externalised:

import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

export default defineConfig({
plugins: [react()],
build: {
lib: {
entry: "src/manifest.ts",
formats: ["es"],
fileName: () => "bundle.js",
},
outDir: "dist",
emptyOutDir: true,
sourcemap: false,
rollupOptions: {
external: [
"react",
"react/jsx-runtime",
"react-dom",
"react-dom/client",
"react-i18next",
"@flexweg/cms-runtime",
],
output: {
inlineDynamicImports: true,
},
},
},
});

Three musts:

  1. formats: ["es"] — ESM only, the admin doesn't load CommonJS
  2. external — these MUST stay external (the admin's import-map redirects them at runtime to the admin's instances)
  3. inlineDynamicImports: true — separate chunks would 404; everything goes into one file

TypeScript types

The example's src/types/cms-runtime.d.ts declares @flexweg/cms-runtime as a module with the types your plugin uses:

declare module "@flexweg/cms-runtime" {
export interface PluginManifest<TConfig = unknown> { /* … */ }
export interface PublishContext { /* … */ }
export interface BaseLayoutProps { /* … */ }
export interface Post { /* … */ }
// … (mirror the types you import from in your code)
}

This is a stopgap until we publish a real @flexweg/cms-runtime package. Until then, copy the relevant types from src/plugins/index.ts (PluginManifest), src/services/publisher.ts (PublishContext), src/themes/types.ts, and src/core/types.ts (Post, Term, Media, …).

Building

npm install
npm run build

Outputs <id>.zip containing bundle.js + manifest.json.

Installing

In the admin → PluginsInstall plugin → pick the ZIP.

After upload + reload the plugin appears in the Plugins tab as disabled. Click Enable to activate.

Updating an installed plugin

Re-installing the same plugin id overwrites the existing files on Flexweg. Plugin settings ARE preserved (they're in Firestore, not in the bundle). The new bundle is fetched on the next admin reload.

API version compatibility

Same mechanism as external themes:

admin.FLEXWEG_API_MIN_VERSION ≤ plugin.apiVersion ≤ admin.FLEXWEG_API_VERSION

Bump your plugin's apiVersion when you adopt newer admin features; document compatibility in your README.

Common pitfalls

"Cannot find module 'react'"

Your bundle is bundling React instead of externalising it. Check rollupOptions.external includes all six entries.

"Plugin appears installed but doesn't run"

Check that the plugin is enabled (Plugins tab → toggle button). External plugins install as disabled by default.

"Plugin runs but i18n keys are showing instead of translations"

The i18n manifest field needs the namespace to match your plugin id. useTranslation("<plugin-id>") in your settings page must match id in the manifest.

"Bundle.js fetch fails with parse error"

The bundle has syntax issues. Most commonly:

  • Vite output format isn't ESM (check formats: ["es"])
  • Code uses Node-only APIs (e.g. fs, path) — those don't work in the browser
  • TypeScript decorators or other features that need build-time transformation aren't being stripped

Things external plugins CAN do

External plugins have the same admin privileges as in-tree plugins:

  • Read and write Firestore
  • Read and write Flexweg files via flexwegApi
  • Register filters / actions / blocks / cards / regen targets
  • Open dialogs / toasts
  • Use any @flexweg/cms-runtime export

Things external plugins CAN'T do

  • Add new routes to the admin's React Router. Admin routes are baked into the admin bundle.
  • Modify admin chrome (sidebar, topbar layout). Use plugin settings pages instead.
  • Hook into editor selection events beyond what BlockManifest exposes. The editor's selection event bus isn't part of the public runtime API.
  • Persist arbitrary state at module-load time. Use Firestore.

Continue