Theme settings page
A theme can ship a settings page reachable at /theme-settings when the theme is active. Use it to expose customisation knobs — colours, fonts, logo, layout toggles — without forking the theme.
Why bother
If your theme is going to be used by anyone other than you (or even by you on multiple sites), runtime customisation beats forking. Without a settings page:
- Every change requires editing source code
- Every site needs its own fork to customise
- Updates from the upstream theme require manual merging
With a settings page:
- Admins customise via the UI
- One theme bundle serves many sites
- Updates apply uniformly while preserving each site's customisations
Declaring a settings page
In manifest.ts:
import { MyThemeSettingsPage } from "./SettingsPage";
export const manifest: ThemeManifest<MyThemeConfig> = {
// …
settings: {
navLabelKey: "title", // i18n key for the sidebar label
defaultConfig: {
logoEnabled: true,
colors: { primary: "#3b82f6" },
// …
},
component: MyThemeSettingsPage,
},
};
Three required fields:
navLabelKey— i18n key resolved against the theme's i18n bundle. Used for the sidebar entry label and the page heading.defaultConfig— typedTConfig. Merged with the user's stored config before the page renders, so a fresh install behaves predictably.component— React component that receives{ config, save }.
The settings component
import { useState } from "react";
import { useTranslation } from "react-i18next";
import type { ThemeSettingsPageProps } from "../types";
import type { MyThemeConfig } from "./config";
export function MyThemeSettingsPage({ config, save }: ThemeSettingsPageProps<MyThemeConfig>) {
const { t } = useTranslation("theme-my-theme");
const [draft, setDraft] = useState<MyThemeConfig>(config);
const dirty = JSON.stringify(draft) !== JSON.stringify(config);
return (
<form onSubmit={async (e) => { e.preventDefault(); await save(draft); }}>
<h2>{t("title")}</h2>
<label>
{t("logo.enabled")}
<input
type="checkbox"
checked={draft.logoEnabled}
onChange={(e) => setDraft({ ...draft, logoEnabled: e.target.checked })}
/>
</label>
<button type="submit" disabled={!dirty}>{t("save")}</button>
</form>
);
}
The component receives:
config: TConfig— the resolved config (manifest defaults + user's stored values, merged)save: (next: TConfig) => Promise<void>— call to persist changes
save writes to settings.themeConfigs.<theme-id> in Firestore via updateThemeConfig. The same Firestore subscription that drives CmsDataContext also drives this page's rehydration, so saves from another tab flow back automatically.
Tabs
The settings framework doesn't ship tab UI — it's a theme decision. The default theme renders its own tab strip:
const [tab, setTab] = useState<"general" | "style">("general");
return (
<>
<nav className="tabs">
<button onClick={() => setTab("general")} aria-current={tab === "general"}>
{t("tabs.general")}
</button>
<button onClick={() => setTab("style")} aria-current={tab === "style"}>
{t("tabs.style")}
</button>
</nav>
{tab === "general" && <GeneralTab draft={draft} setDraft={setDraft} />}
{tab === "style" && <StyleTab draft={draft} setDraft={setDraft} />}
</>
);
Use whatever tab UI fits your design. Common choices: top horizontal tabs, left sidebar tabs, accordion sections.
i18n
Bundled translations are loaded into a dedicated namespace named theme-<id>:
// manifest.ts
import { en, fr, /* … */ } from "./i18n";
export const manifest: ThemeManifest = {
// …
i18n: { en, fr, /* … */ },
};
// i18n.ts
export const en = {
title: "My Theme settings",
save: "Save",
tabs: { general: "General", style: "Style" },
logo: { enabled: "Show logo" },
};
export const fr = {
title: "Réglages du thème My Theme",
save: "Enregistrer",
tabs: { general: "Général", style: "Style" },
logo: { enabled: "Afficher le logo" },
};
In your settings component:
const { t } = useTranslation("theme-my-theme");
t("title") // → "My Theme settings"
t("tabs.general") // → "General"
Always provide all 7 supported locales (en, fr, de, es, nl, pt, ko) — even if it's just copying the English. Missing locales fall back to English at runtime, but the settings page UI shows English in mid-localised mode which can look broken.
Persistence
Configs are stored at settings.themeConfigs.<theme-id> in Firestore. Importantly:
- Each theme's config is preserved when you switch themes. Switching from your theme → another → back restores the user's settings.
- Uninstalling the theme does NOT delete the config. Re-installing later restores configured behaviour.
This is intentional. If you want a hard reset, the settings page should expose a Reset to defaults button that calls save(manifestDefaults).
Triggering CSS regeneration on save
If your settings affect CSS (which they usually do), wire save to also regenerate the CSS:
import { applyAndUploadCustomCss } from "@flexweg/cms-runtime"; // or your own helper
async function handleSave(next: MyThemeConfig) {
await save(next);
await applyAndUploadCustomCss({
themeId: "my-theme",
baseCssText: cssText,
config: next,
});
}
applyAndUploadCustomCss is theme-specific — implement your own helper that:
- Calls your
compileCss(config)hook - Uploads the result to
theme-assets/<id>.cssviauploadFile
The default theme's helper is in src/themes/default/style.ts.
Triggering HTML regeneration on save
Some settings affect templates, not just CSS — e.g. a "show author bio" toggle that changes the SingleTemplate's output. For those, also trigger HTML regeneration:
async function handleSave(next: MyThemeConfig) {
await save(next);
if (next.showAuthorBio !== config.showAuthorBio) {
// template-affecting change — show a hint
toast.info(t("save.regenHint"));
}
}
Most sites won't auto-regenerate (5 000-page sites would lock up). Instead, the page hints at the action ("Run Themes → Regenerate site → All HTML pages to apply") and lets the admin trigger it manually.
Built-in patterns
The default theme's settings page covers a few patterns you can crib:
- Logo upload: file picker → resize to WebP → upload to
theme-assets/<id>-logo.webp→ store{ logoEnabled, logoUpdatedAt }in config. Cache-busted via?v=<timestamp>. - Style tokens: 22 design tokens (colours, spacing, radius) editable in a grid. Each has a "reset" ↺ button that clears the override.
- Font picker: dropdown of curated Google Fonts pairs. Stored as a pair name;
compileCssrewrites the@import url(...)line.
See src/themes/default/SettingsPage.tsx.
Continue
- Theme manifest
- CSS pipelines — how
compileCssties in - Plugin settings page — the parallel for plugins