Skip to main content

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 — typed TConfig. 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:

  1. Calls your compileCss(config) hook
  2. Uploads the result to theme-assets/<id>.css via uploadFile

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; compileCss rewrites the @import url(...) line.

See src/themes/default/SettingsPage.tsx.

Continue