Skip to main content

Theme manifest reference

Every theme exports a single ThemeManifest<TConfig> from its manifest.ts. This is the contract between the theme and the admin. Get this right and everything else falls into place.

Full shape

export interface ThemeManifest<TConfig = unknown> {
id: string;
name: string;
version: string;
description?: string;
imageFormats?: ImageFormatConfig;
scssEntry: string;
cssText: string;
jsText?: string;
jsTextPosts?: string;
templates: {
base: ComponentType<BaseLayoutProps>;
home: ComponentType<HomeTemplateProps & { site: SiteContext }>;
single: ComponentType<SingleTemplateProps & { site: SiteContext }>;
category: ComponentType<CategoryTemplateProps & { site: SiteContext }>;
author: ComponentType<AuthorTemplateProps & { site: SiteContext }>;
notFound: ComponentType<NotFoundTemplateProps & { site: SiteContext }>;
};
settings?: ThemeSettingsPageDef<TConfig>;
i18n?: Partial<Record<AdminLocale, Record<string, unknown>>>;
compileCss?: (config: TConfig) => string;
blocks?: BlockManifest[];
register?: (api: PluginApi) => void;
}

Required fields

id: string

Stable identifier used as the folder name (/admin/themes/<id>/), the CSS filename (/theme-assets/<id>.css), the i18n namespace (theme-<id>), and the Firestore key (themeConfigs.<id>). Must be lower-case ASCII + dashes. Cannot collide with any other theme's id, including built-ins (default, magazine, corporate).

name: string

Human-readable label shown in the Themes list. Localised display is up to the theme — use the translation bundle if you want it translated.

version: string

Semver string. Shown in the theme card. Used by the install flow to detect upgrades when re-installing the same id.

scssEntry: string

Path of the SCSS entrypoint relative to the theme directory. The build script reads this to know what to compile. Even if your theme uses Tailwind (where the input is a .css file, not .scss), this field is still required — set it to your entry CSS file.

cssText: string

The compiled CSS embedded in the admin bundle as a string. Always import via Vite's ?inline suffix:

import cssText from "./theme.scss?inline";

This is what the Sync theme assets button uploads to Flexweg. By embedding the CSS string in the admin bundle, the admin always pushes the CSS that was built alongside it — no chicken-and-egg.

templates

Six React components, one per page type. Each receives site: SiteContext plus per-template props. See Template props reference for the full type shape of each.

The components MUST be pure — no Firestore hooks, no admin context. The publisher passes plain serializable props.

Optional fields

description?: string

One-line description shown in the Themes list. Localise via the i18n bundle if needed.

imageFormats?: ImageFormatConfig

The image variants the theme expects. The upload pipeline generates each format declared here for every uploaded image. See Image variants.

Themes that omit this get only the admin-only formats (admin-thumb, admin-preview) — useful for themes that don't display images at all (rare).

jsText?: string

Companion JS shipped with the theme. Imported via ?raw:

import jsText from "./menu-loader.js?raw";

Uploaded to /theme-assets/<id>-menu.js. Referenced by your BaseLayout via <script src="/theme-assets/<id>-menu.js" defer />. Used by built-in themes for the dynamic menu loader (reads [data-cms-menu] containers, populates from /menu.json).

jsTextPosts?: string

Second optional JS file. Same lifecycle — uploaded to /theme-assets/<id>-posts.js, referenced from BaseLayout. Built-in themes use this for sidebar widgets ([data-cms-related], related-posts, contact form runtime).

settings?: ThemeSettingsPageDef<TConfig>

Theme settings page. When defined, an entry Theme settings appears in the sidebar. See Theme settings page.

settings: {
navLabelKey: "title", // i18n key for the sidebar label
defaultConfig: { /* TConfig */ },
component: MyThemeSettingsPage,
}

i18n?

Bundled translations. Loaded into a dedicated namespace named theme-<id>:

import { en, fr, de, es, nl, pt, ko } from "./i18n";

i18n: { en, fr, de, es, nl, pt, ko }

Each locale file is a flat Record<string, unknown> of translation keys. The settings page calls useTranslation("theme-<id>") to scope its keys.

compileCss?: (config: TConfig) => string

CSS transformer. Called by Sync theme assets with the resolved theme config; the return value is uploaded instead of the raw cssText. Themes use this to bake user overrides (colour palette, fonts) into the live CSS.

Without compileCss, syncing pushes the build-time cssText verbatim — wiping any runtime customisations. The hook is what makes Theme Settings → Style customisations stick.

The default theme's implementation:

  1. Take the bundled cssText.
  2. Find the @import url(...) line for fonts; replace with the user-chosen pair.
  3. Append a :root { … } block with overridden vars.
  4. Return the result.

See src/themes/default/style.ts.

blocks?: BlockManifest[]

Editor blocks the theme contributes. Registered when the theme becomes active; cleared when another theme activates. Use this for theme-specific layout primitives (Hero block, Posts list block) that depend on the theme's own CSS.

blocks: [heroBlock, postsListBlock, ctaBlock]

See Theme blocks for how to write block manifests. The shape is identical to plugin blocks.

register?: (api: PluginApi) => void

Optional registration callback invoked when the theme becomes active. Use this to register filters / actions tied to the theme's blocks. Called after the theme's blocks are registered.

register(api) {
api.addFilter<string>("post.html.body", (html, ctx) => {
return transformBodyHtml(html, ctx);
});
}

The default theme uses this to register a single post.html.body filter that scans for the theme's block markers and swaps them for real markup.

Field interactions

A few combinations matter:

compileCss + settings

If you provide a settings page, you almost certainly also want compileCss — otherwise saving theme settings doesn't change anything. The save flow calls compileCss(savedConfig) and uploads the result.

blocks + register

If you provide blocks, you typically also need register to handle the block markers in published HTML. Otherwise the markers reach the published HTML unchanged.

jsText + BaseLayout <script> tag

If you ship jsText, BaseLayout must include <script src="/theme-assets/<id>-menu.js" defer />. The string isn't auto-injected — the manifest field tells the publisher what to upload, BaseLayout decides where to load it.

Build-time vs runtime

cssText, jsText, jsTextPosts, i18n, the React templates — all of these are bundled into the admin at build time. There's no late binding; the admin can't fetch a theme's CSS at runtime (well, except for external themes — see External bundle).

settings.themeConfigs.<id> IS runtime — saved in Firestore, read on every theme activation, passed through compileCss to produce the live CSS.

Continue