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​