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.jsonat the project root — installation metadata read by the admin's install flow (id, version, apiVersion, entry path)src/manifest.ts— the actualPluginManifestobject 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:
formats: ["es"]— ESM only, the admin doesn't load CommonJSexternal— these MUST stay external (the admin's import-map redirects them at runtime to the admin's instances)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 → Plugins → Install 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-runtimeexport
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
- Installing external plugins — admin-side install flow
- Plugin manifest reference
- Runtime API reference —
@flexweg/cms-runtimeexports