Building an external theme bundle
External themes ship as a .zip containing a pre-built ESM bundle, get uploaded into Flexweg via the Install theme UI, and load at runtime via dynamic import() — no admin rebuild required.
This is the way to distribute themes when you don't maintain your own admin fork.
Reference example
examples/external-theme/ is a complete minimal theme — clone it, rename it, customise it. The build config + ZIP packaging are already set up.
Project structure
my-external-theme/
├── package.json
├── tsconfig.json
├── vite.config.ts
├── src/
│ ├── manifest.tsx — default-exports ThemeManifest
│ ├── theme.css — your CSS (copied to ZIP root)
│ ├── templates/
│ │ ├── BaseLayout.tsx
│ │ ├── HomeTemplate.tsx
│ │ ├── SingleTemplate.tsx
│ │ ├── CategoryTemplate.tsx
│ │ ├── AuthorTemplate.tsx
│ │ └── NotFoundTemplate.tsx
│ └── types/
│ └── cms-runtime.d.ts — local types for @flexweg/cms-runtime
├── scripts/
│ └── pack-zip.mjs — bundles dist/ + manifest.json + theme.css
└── manifest.json — installation metadata (NOT the ThemeManifest)
Two manifest files — different things:
manifest.jsonat the project root — installation metadata read by the admin's install flow (id, version, apiVersion, entry path)src/manifest.tsx— the actualThemeManifestobject the admin registers at runtime
manifest.json (installation metadata)
{
"id": "my-theme",
"name": "My Theme",
"version": "1.0.0",
"apiVersion": "1.0.0",
"entry": "bundle.js",
"description": "A great external theme."
}
Fields:
id— unique theme id. Sanitised to lowercase ASCII + dashes by the install flow.name/version/description— display in the Themes list.apiVersion— Flexweg CMS API version this theme was built against. Validated against the admin's[FLEXWEG_API_MIN_VERSION, FLEXWEG_API_VERSION]range. Mismatches abort the install.entry— relative path of the bundle inside the ZIP. Conventionallybundle.js.
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.tsx",
formats: ["es"],
fileName: () => "bundle.js",
},
outDir: "dist",
emptyOutDir: true,
sourcemap: false,
rollupOptions: {
external: [
"react",
"react/jsx-runtime",
"react-dom",
"react-dom/client",
"@flexweg/cms-runtime",
],
output: {
inlineDynamicImports: true,
},
},
},
});
Three things matter:
formats: ["es"]— the admin only loads ESM bundles. CommonJS won't work.external— those modules MUST stay external. The admin's import-map redirects them at runtime to the admin's React instance. If you bundle React, you'll have two copies in the page → broken hooks.inlineDynamicImports: true— the admin loads onlybundle.js; separate chunks would 404. Inline everything into one file.
CSS handling
External theme CSS is NOT bundled into bundle.js. Instead:
- Your
theme.css(ortheme.scss,theme.compiled.css— whichever name you use) sits alongsidebundle.jsin the ZIP root. - Your manifest imports it via
?raw:import cssText from "./theme.css?raw";// …export default { cssText, /* … */ }; - The admin's Sync theme assets path uploads
cssTextverbatim to/theme-assets/<id>.css.
You can use Tailwind, SCSS, or hand-written CSS — whatever produces the final theme.css. The admin doesn't care; it just gets the string.
For Tailwind:
- Run the Tailwind CLI to compile to
theme.cssbefore packaging - Wire it into your build script
For SCSS:
- Either run
sassseparately, or use Vite's built-in SCSS compilation with?inline - Either way, end up with
theme.cssin the ZIP
TypeScript types
The example's src/types/cms-runtime.d.ts declares @flexweg/cms-runtime as a module with the types your theme uses:
declare module "@flexweg/cms-runtime" {
export interface ThemeManifest<TConfig = unknown> { /* … */ }
export interface SiteContext { /* … */ }
export interface BaseLayoutProps { /* … */ }
// … (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/themes/types.ts and src/core/types.ts.
Building
npm install
npm run build
This:
- Runs Vite to build
dist/bundle.js - Runs
scripts/pack-zip.mjs(or equivalent) to bundledist/bundle.js+manifest.json+theme.cssinto<id>.zip
The example uses archiver for ZIP creation. Any zipper works.
Installing
In the admin → Themes → Install theme → pick the ZIP.
After upload + reload the theme appears in the Themes list. Click Activate to switch the site to it.
The first activation runs Sync theme assets automatically — uploads theme.css to /theme-assets/<id>.css. Subsequent code changes can be deployed with Install theme again (overwrite); the new bundle is fetched on the next admin reload.
Updating an installed theme
Re-installing the same theme id overwrites the existing files on Flexweg. The runtime registry entry is updated with the new version. Theme settings ARE preserved (they're in Firestore, not in the bundle).
If you change the manifest's imageFormats and existing images don't have the new variants — same caveat as in-tree theme switches: the new variants don't auto-generate. Re-upload affected images to get the new variants.
API version compatibility
apiVersion in manifest.json is the Flexweg CMS API version your theme targets. The admin checks:
admin.FLEXWEG_API_MIN_VERSION ≤ theme.apiVersion ≤ admin.FLEXWEG_API_VERSION
If the range doesn't include your theme's version, the install aborts with a clear error. Bump your theme's apiVersion when you upgrade against 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 in the example.
"Theme bundle.js id doesn't match manifest.json id"
The admin cross-checks bundle.default.id === manifest.id to prevent slot hijacking. Make sure src/manifest.tsx's default export has id: "<same as manifest.json>".
"Theme appears installed but doesn't activate"
Open the browser devtools network tab and look for the bundle.js fetch. 404 = the upload didn't succeed; non-404 with parse errors = the bundle has syntax issues (CommonJS instead of ESM, malformed JS, etc.).
"Theme CSS isn't on the public site"
Click Sync theme assets manually. The first activation should do it automatically, but if there was an error during the activation, this re-runs the upload.
Continue
- Installing external themes — admin-side install flow
- Theme manifest reference — every field
- Runtime API reference —
@flexweg/cms-runtimeexports