Plugin settings page

Plugins can ship a configuration page reachable at . The page lives inside the standard Settings layout — same chrome as General + Performance — so admins get a consistent surface.

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:

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

TSX
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):

TS
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:

TSX
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:

TS
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

TS
// 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:

TS
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)

TSX
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)

TSX
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:

TSX
<p className="text-sm text-gray-500">
  {t("note.appliesOnPublish")}
</p>

Or:

TSX
<button onClick={handleForceRegenerate}>
  {t("forceRegenerateNow")}
</button>

So admins know whether to wait for the next publish or trigger regen immediately.

Continue