Skip to main content

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:

  1. The publisher loads everything from Firestore (post, author, terms, media, settings).
  2. It resolves the post's URL via buildPostUrl(post, terms, settings).
  3. It runs the Markdown body through marked + DOMPurify → HTML.
  4. Plugins' post.html.body filters transform the HTML (block markers → real markup).
  5. The active theme's SingleTemplate is wrapped in BaseLayout with all the props prepared.
  6. renderToStaticMarkup produces the final HTML string.
  7. Plugins' page.head.extra and page.body.end filter results replace sentinel placeholders.
  8. 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:

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:

  • BaseLayout must 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