Skip to main content

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.json at the project root — installation metadata read by the admin's install flow (id, version, apiVersion, entry path)
  • src/manifest.tsx — the actual ThemeManifest object 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. Conventionally bundle.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:

  1. formats: ["es"] — the admin only loads ESM bundles. CommonJS won't work.
  2. 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.
  3. inlineDynamicImports: true — the admin loads only bundle.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 (or theme.scss, theme.compiled.css — whichever name you use) sits alongside bundle.js in 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 cssText verbatim 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:

  1. Run the Tailwind CLI to compile to theme.css before packaging
  2. Wire it into your build script

For SCSS:

  1. Either run sass separately, or use Vite's built-in SCSS compilation with ?inline
  2. Either way, end up with theme.css in 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:

  1. Runs Vite to build dist/bundle.js
  2. Runs scripts/pack-zip.mjs (or equivalent) to bundle dist/bundle.js + manifest.json + theme.css into <id>.zip

The example uses archiver for ZIP creation. Any zipper works.

Installing

In the admin → ThemesInstall 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