Plugin settings page
Plugins can ship a configuration page reachable at /settings/plugin/<id>. The page lives inside the standard Settings layout — same chrome as General + Performance — so admins get a consistent surface.
This page documents the authoring side. For the user-facing UX, see Settings → Plugin settings.
Declaring a settings page
In manifest.ts:
import { MyPluginSettingsPage, DEFAULT_CONFIG } from "./SettingsPage";
import type { MyPluginConfig } from "./SettingsPage";
export const manifest: PluginManifest<MyPluginConfig> = {
// …
settings: {
navLabelKey: "title",
defaultConfig: DEFAULT_CONFIG,
component: MyPluginSettingsPage,
},
};
Fields:
navLabelKey— i18n key resolved against your plugin's i18n namespace. Used for the sidebar tab label.defaultConfig— typedTConfig. Merged with the user's stored config before the page renders, so a fresh install behaves predictably without an explicit save.component— React component receiving{ config, save }.
The settings component
import { useState } from "react";
import { useTranslation } from "react-i18next";
import type { PluginSettingsPageProps } from "@flexweg/cms-runtime";
export interface MyPluginConfig {
enabled: boolean;
threshold: number;
}
export const DEFAULT_CONFIG: MyPluginConfig = {
enabled: true,
threshold: 100,
};
export function MyPluginSettingsPage({ config, save }: PluginSettingsPageProps<MyPluginConfig>) {
const { t } = useTranslation("my-plugin");
const [draft, setDraft] = useState<MyPluginConfig>(config);
const dirty = JSON.stringify(draft) !== JSON.stringify(config);
return (
<form onSubmit={async (e) => { e.preventDefault(); await save(draft); }}>
<h2>{t("title")}</h2>
<label>
<input
type="checkbox"
checked={draft.enabled}
onChange={(e) => setDraft({ ...draft, enabled: e.target.checked })}
/>
{t("enabled")}
</label>
<label>
{t("threshold")}
<input
type="number"
value={draft.threshold}
onChange={(e) => setDraft({ ...draft, threshold: Number(e.target.value) })}
/>
</label>
<button type="submit" disabled={!dirty}>{t("save")}</button>
</form>
);
}
Persistence
Configs are stored at settings.pluginConfigs.<plugin-id> in Firestore. The save helper:
- Patches the doc via
updatePluginConfig(<plugin-id>, next) - Triggers the live Firestore subscription that drives
CmsDataContext - Plugin hook handlers (which read
props.site.settings.pluginConfigs.<id>) see the new config on the next fire
So saving the settings page is enough — no need to manually re-register filters or refresh anything.
Reading config from hooks
Inside any registered handler, the live config is at ctx.settings.pluginConfigs.<id> (for action hooks) or props.site.settings.pluginConfigs.<id> (for filter hooks):
function readConfig(props: BaseLayoutProps): MyConfig {
const stored = props.site.settings.pluginConfigs?.["my-plugin"] as
| Partial<MyConfig>
| undefined;
return { ...DEFAULT_CONFIG, ...(stored ?? {}) };
}
api.addFilter<string>("page.head.extra", (current, props) => {
const config = readConfig(props);
if (!config.enabled) return current;
return current + buildTags(config);
});
The merge with DEFAULT_CONFIG is the standard pattern — handles fresh installs (no entry yet) and partial saves (legacy installs with older config shapes) gracefully.
Force regenerate buttons
Many plugins expose a Force regenerate button that re-runs the plugin's full work pass — useful after large config changes. Implementation pattern:
import { regenerateAll } from "./generator";
export function MyPluginSettingsPage({ config, save }: PluginSettingsPageProps<MyPluginConfig>) {
const { settings, posts, terms } = useCmsData();
async function handleForceRegenerate() {
await regenerateAll({ posts, terms, settings, config });
toast.success(t("regenerateDone"));
}
return (
<>
{/* form fields */}
<button onClick={handleForceRegenerate}>{t("forceRegenerate")}</button>
</>
);
}
The same logic typically also runs as a Regeneration target so the Themes → Regenerate site → My Plugin dropdown entry calls it:
register(api) {
api.registerRegenerationTarget({
id: "my-plugin",
labelKey: "regenerationTarget.label",
descriptionKey: "regenerationTarget.description",
priority: 200,
run: async (ctx, log) => {
log({ level: "info", message: "Regenerating my plugin's files…" });
const result = await regenerateAll({
posts: ctx.posts,
terms: ctx.terms,
settings: ctx.settings,
config: readConfig({ site: { settings: ctx.settings } }),
});
log({ level: "success", message: `Regenerated ${result.length} files.` });
},
});
}
So Force regenerate (in the settings page) and Regenerate site → My Plugin (in the Themes dropdown) call the same code. Two surfaces, one regenerator.
i18n
// i18n.ts
export const en = {
title: "My Plugin settings",
enabled: "Enable plugin behaviour",
threshold: "Threshold",
save: "Save",
forceRegenerate: "Force regenerate",
regenerateDone: "Done.",
regenerationTarget: {
label: "My Plugin",
description: "Re-runs my plugin's full work pass.",
},
};
export const fr = { /* … */ };
// + de, es, nl, pt, ko
Plus reference them in the manifest:
import { en, fr, de, es, nl, pt, ko } from "./i18n";
export const manifest: PluginManifest<MyConfig> = {
// …
i18n: { en, fr, de, es, nl, pt, ko },
};
Save patterns: form vs immediate
Two common approaches:
Form save (recommended for most plugins)
const [draft, setDraft] = useState(config);
const dirty = JSON.stringify(draft) !== JSON.stringify(config);
return (
<form onSubmit={(e) => { e.preventDefault(); save(draft); }}>
{/* fields update `draft` */}
<button disabled={!dirty}>Save</button>
</form>
);
User clicks Save explicitly. Allows previewing changes; allows cancelling.
Immediate save (for one-toggle plugins)
return (
<label>
<input
type="checkbox"
checked={config.enabled}
onChange={(e) => save({ ...config, enabled: e.target.checked })}
/>
{t("enabled")}
</label>
);
Every change immediately saves. Cleaner UX for single-toggle plugins; problematic for multi-field forms (can't preview).
Hint at when changes apply
Most plugin-config changes affect future publishes. Be explicit in the UI:
<p className="text-sm text-gray-500">
{t("note.appliesOnPublish")}
</p>
Or:
<button onClick={handleForceRegenerate}>
{t("forceRegenerateNow")}
</button>
So admins know whether to wait for the next publish or trigger regen immediately.
Continue
- Plugin manifest
- Hooks
- Theme settings page — the parallel for themes
- Settings → Plugin settings — user-facing UX