Plugin Settings

On this page

Plugins need configuration—API keys, feature flags, display preferences. EmDash provides two mechanisms: a settings schema for admin-configurable options and a KV store for programmatic access.

Settings Schema

Declare a settings schema in admin.settingsSchema to auto-generate an admin UI:

import { definePlugin } from "emdash";

export default definePlugin({
	id: "seo",
	version: "1.0.0",

	admin: {
		settingsSchema: {
			siteTitle: {
				type: "string",
				label: "Site Title",
				description: "Used in title tags and meta",
				default: "",
			},
			maxTitleLength: {
				type: "number",
				label: "Max Title Length",
				description: "Characters before truncation",
				default: 60,
				min: 30,
				max: 100,
			},
			generateSitemap: {
				type: "boolean",
				label: "Generate Sitemap",
				description: "Automatically generate sitemap.xml",
				default: true,
			},
			defaultRobots: {
				type: "select",
				label: "Default Robots",
				options: [
					{ value: "index,follow", label: "Index & Follow" },
					{ value: "noindex,follow", label: "No Index, Follow" },
					{ value: "noindex,nofollow", label: "No Index, No Follow" },
				],
				default: "index,follow",
			},
			apiKey: {
				type: "secret",
				label: "API Key",
				description: "Encrypted at rest",
			},
		},
	},
});

EmDash generates a settings form in the plugin’s admin section. Users edit settings without touching code.

Field Types

String

Text input for single-line or multiline strings.

siteTitle: {
  type: "string",
  label: "Site Title",
  description: "Optional help text",
  default: "My Site",
  multiline: false  // Set true for textarea
}

Number

Numeric input with optional min/max constraints.

maxItems: {
  type: "number",
  label: "Maximum Items",
  default: 100,
  min: 1,
  max: 1000
}

Boolean

Toggle switch for true/false values.

enabled: {
  type: "boolean",
  label: "Enabled",
  description: "Turn this feature on or off",
  default: true
}

Select

Dropdown for predefined options.

theme: {
  type: "select",
  label: "Theme",
  options: [
    { value: "light", label: "Light" },
    { value: "dark", label: "Dark" },
    { value: "auto", label: "System" }
  ],
  default: "auto"
}

Secret

Encrypted field for sensitive values like API keys. Never sent to the client after saving.

apiKey: {
  type: "secret",
  label: "API Key",
  description: "Stored encrypted"
}

Accessing Settings

Read settings in hooks and routes via ctx.kv:

"content:beforeSave": async (event, ctx) => {
  // Read a setting
  const maxLength = await ctx.kv.get<number>("settings:maxTitleLength");
  const apiKey = await ctx.kv.get<string>("settings:apiKey");

  // Use defaults if not set
  const limit = maxLength ?? 60;

  ctx.log.info("Using max length", { limit });
  return event.content;
}

Settings are stored with the settings: prefix by convention. This distinguishes user-configurable values from internal plugin state.

KV Store API

The KV store (ctx.kv) is a general-purpose key-value store for plugin data:

interface KVAccess {
	get<T>(key: string): Promise<T | null>;
	set(key: string, value: unknown): Promise<void>;
	delete(key: string): Promise<boolean>;
	list(prefix?: string): Promise<Array<{ key: string; value: unknown }>>;
}

Reading Values

// Get a single value
const enabled = await ctx.kv.get<boolean>("settings:enabled");

// Get with type
const config = await ctx.kv.get<{ url: string; timeout: number }>("state:config");

Writing Values

// Set a value
await ctx.kv.set("settings:lastSync", new Date().toISOString());

// Set complex values
await ctx.kv.set("state:cache", {
	data: items,
	expiry: Date.now() + 3600000,
});

Listing Values

// List all settings
const settings = await ctx.kv.list("settings:");
// Returns: [{ key: "settings:enabled", value: true }, ...]

// List all plugin keys
const all = await ctx.kv.list();

Deleting Values

const deleted = await ctx.kv.delete("state:tempData");
// Returns true if key existed

Key Naming Conventions

Use prefixes to organize KV data:

PrefixPurposeExample
settings:User-configurable preferencessettings:apiKey
state:Internal plugin statestate:lastSync
cache:Cached datacache:results
// Good: clear prefixes
await ctx.kv.set("settings:webhookUrl", url);
await ctx.kv.set("state:lastRun", timestamp);
await ctx.kv.set("cache:feed", feedData);

// Avoid: no prefix, unclear purpose
await ctx.kv.set("url", url);

Settings vs Storage vs KV

Choose the right storage mechanism:

Use CaseMechanism
Admin-editable preferencesadmin.settingsSchema + ctx.kv with settings:
Internal plugin statectx.kv with state:
Collections of documentsctx.storage

Settings are for user-configurable values—things an admin might change. They get an auto-generated UI.

KV is for internal state like timestamps, sync cursors, or cached computations. No UI, just code.

Storage is for document collections with indexed queries—form submissions, audit logs, etc.

Loading Settings in Routes

API routes can expose settings to admin UI components:

routes: {
  settings: {
    handler: async (ctx) => {
      const settings = await ctx.kv.list("settings:");
      const result: Record<string, unknown> = {};

      for (const entry of settings) {
        const key = entry.key.replace("settings:", "");
        result[key] = entry.value;
      }

      return result;
    }
  },

  "settings/save": {
    handler: async (ctx) => {
      const input = ctx.input as Record<string, unknown>;

      for (const [key, value] of Object.entries(input)) {
        if (value !== undefined) {
          await ctx.kv.set(`settings:${key}`, value);
        }
      }

      return { success: true };
    }
  }
}

Default Values

Settings from settingsSchema are not automatically persisted. They’re defaults in the admin UI. Your code should handle missing values:

"content:afterSave": async (event, ctx) => {
  // Always provide a fallback
  const enabled = await ctx.kv.get<boolean>("settings:enabled") ?? true;
  const maxItems = await ctx.kv.get<number>("settings:maxItems") ?? 100;

  if (!enabled) return;
  // ...
}

Alternatively, persist defaults in plugin:install:

hooks: {
  "plugin:install": async (_event, ctx) => {
    // Persist schema defaults
    await ctx.kv.set("settings:enabled", true);
    await ctx.kv.set("settings:maxItems", 100);
  }
}

Storage Implementation

KV values are stored in the _options table with plugin-namespaced keys:

INSERT INTO _options (name, value) VALUES
  ('plugin:seo:settings:siteTitle', '"My Site"'),
  ('plugin:seo:settings:maxTitleLength', '60');

The plugin:seo: prefix is added automatically. Your code uses settings:siteTitle, and EmDash stores it as plugin:seo:settings:siteTitle.

This ensures plugins can’t accidentally overwrite each other’s data.