Creating themes
A theme controls how your public site looks: colours, typography, layout, the markup of every published page. Flexweg CMS themes are React components that get rendered to HTML at publish time — you write JSX, the publisher calls react-dom/server.renderToStaticMarkup() in the admin's browser, and the resulting string gets uploaded to Flexweg.
Themes can ship as either:
- In-tree themes — a folder under
src/themes/that's bundled into the admin alongside the built-ins. Best when you maintain your own admin fork. - External themes — a ZIP with a pre-built ESM bundle, installed at runtime via the Install theme button. Best when you want to distribute themes independently of admin releases.
Both share the same manifest shape and runtime API. This section walks through the in-tree path first (simpler to develop against) then covers what's different for external themes.
What a theme is, structurally
Every theme has:
src/themes/<id>/
├── manifest.ts — the theme definition (id, version, templates, CSS, etc.)
├── theme.scss — styles (or theme.css + tailwind.config.cjs for the Tailwind pipeline)
├── templates/
│ ├── BaseLayout.tsx — <html> shell shared by every page
│ ├── HomeTemplate.tsx — the home page
│ ├── SingleTemplate.tsx — single post / page
│ ├── CategoryTemplate.tsx — category archive
│ ├── AuthorTemplate.tsx — author page
│ └── NotFoundTemplate.tsx — 404 page
├── components/ — shared React components used by templates
├── i18n.ts — admin UI translations (when the theme has a settings page)
├── (optional) SettingsPage.tsx — admin-side settings form
├── (optional) menu-loader.js — runtime script for dynamic menus
├── (optional) posts-loader.js — runtime script for sidebar widgets / contact forms
└── (optional) blocks/ — custom blocks the editor exposes when this theme is active
The minimum is manifest.ts + 6 templates + theme.scss. Everything else is optional.
The manifest
import type { ThemeManifest } from "../types";
import cssText from "./theme.scss?inline";
import { BaseLayout, HomeTemplate, /* … */ } from "./templates";
export const manifest: ThemeManifest = {
id: "my-theme",
name: "My Theme",
version: "1.0.0",
description: "Short description shown in Themes list.",
scssEntry: "theme.scss",
cssText,
templates: {
base: BaseLayout,
home: HomeTemplate,
single: SingleTemplate,
category: CategoryTemplate,
author: AuthorTemplate,
notFound: NotFoundTemplate,
},
imageFormats: {
inputFormats: [".jpg", ".jpeg", ".png", ".webp", ".gif"],
outputFormat: "webp",
quality: 80,
formats: {
small: { width: 480, height: 480, fit: "cover" },
medium: { width: 800, height: 800, fit: "cover" },
large: { width: 1600, height: 900, fit: "cover" },
},
defaultFormat: "medium",
},
};
This is enough for a working theme. See Theme manifest reference for every field.
How rendering works
When a post is published:
- The publisher loads everything from Firestore (post, author, terms, media, settings).
- It resolves the post's URL via
buildPostUrl(post, terms, settings). - It runs the Markdown body through
marked+ DOMPurify → HTML. - Plugins'
post.html.bodyfilters transform the HTML (block markers → real markup). - The active theme's
SingleTemplateis wrapped inBaseLayoutwith all the props prepared. renderToStaticMarkupproduces the final HTML string.- Plugins'
page.head.extraandpage.body.endfilter results replace sentinel placeholders. - The result is uploaded to Flexweg.
Themes only see step 5. Everything else is the publisher's job.
What templates receive
Each template has a typed props shape (HomePageProps, SinglePageProps, etc.) — see Template props reference. The key constraint:
Theme components must only accept serializable props. No Firestore hooks, no admin context, no closures over admin state. The publisher resolves everything into plain data (URLs, MediaView shapes, ResolvedMenuItems) before handing them to your template. This is what lets renderToStaticMarkup produce a clean HTML string with no React runtime code.
Building from scratch vs. forking
Two starting points:
Fork an existing theme (recommended)
Copy src/themes/default/ to src/themes/<your-id>/, change the manifest.id and the i18n bundle keys, edit templates / SCSS to taste. You inherit all the boilerplate (sentinel handling, runtime loaders, image variants).
From scratch
Create the folder + minimum manifest + 6 templates. Watch out for:
BaseLayoutmust include the<meta name="x-cms-head-extra" />and<script type="application/x-cms-body-end" />sentinels — otherwise plugin filters silently no-op- The
<link rel="stylesheet" href={cssHref} />in<head>references/theme-assets/<id>.css— that file is uploaded by the Sync theme assets button
For first-time theme authors, fork. The minimum-viable from-scratch version still has surprising hooks to wire up.
Hot-reload during development
npm run dev picks up theme changes via Vite HMR — edit a template and your editor's preview pane reflects the change without a full reload.
For published-page changes (you're testing what the actual rendered HTML looks like): publish a test post, edit the template, click Themes → Regenerate site → All HTML pages to push the changes to Flexweg.
Continue
- Theme manifest reference — every field
- Templates and props — what each template receives
- CSS pipelines (SCSS vs Tailwind)
- Image variants
- Theme settings page
- Theme blocks
- Building an external theme bundle