Skip to content

Plugin Settings

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.

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.

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
}

Numeric input with optional min/max constraints.

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

Toggle switch for true/false values.

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

Dropdown for predefined options.

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

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

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

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.

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 }>>;
}
// 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");
// 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,
});
// 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();
const deleted = await ctx.kv.delete("state:tempData");
// Returns true if key existed

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

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.

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 };
}
}
}

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);
}
}

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.