Skip to main content

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

  1. Patches the doc via updatePluginConfig(<plugin-id>, next)
  2. Triggers the live Firestore subscription that drives CmsDataContext
  3. 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:

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