---
title: Plugin Settings
description: Configure plugins with settings schemas and the KV store.
---

import { Aside, Tabs, TabItem } from "@astrojs/starlight/components";

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:

```typescript
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.

```typescript
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.

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

### Boolean

Toggle switch for true/false values.

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

### Select

Dropdown for predefined options.

```typescript
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.

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

<Aside type="caution">
	Secret fields are encrypted at rest using the site's encryption key. They're never exposed in the
	admin UI after the initial save—only a masked placeholder is shown.
</Aside>
### Url

Single-line text input with URL validation.

```typescript
website: {
	type: "url",
	label: "Website URL",
	description: "Enter your website address",
	default: "https://",
}
```

### Email

Text input with email format validation.

```typescript
contactEmail: {
	type: "email",
	label: "Contact Email",
	description: "Support contact address",
	default: "",
}
```

### Accessing Settings

Read settings in hooks and routes via `ctx.kv`:

```typescript
"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:

```typescript
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

```typescript
// 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

```typescript
// 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

```typescript
// 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

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

## Key Naming Conventions

Use prefixes to organize KV data:

| Prefix      | Purpose                       | Example           |
| ----------- | ----------------------------- | ----------------- |
| `settings:` | User-configurable preferences | `settings:apiKey` |
| `state:`    | Internal plugin state         | `state:lastSync`  |
| `cache:`    | Cached data                   | `cache:results`   |

```typescript
// 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);
```

<Aside type="tip">
	The `settings:` prefix is a convention for values shown in the auto-generated settings UI. Other
	prefixes are for plugin-internal use.
</Aside>

## Settings vs Storage vs KV

Choose the right storage mechanism:

| Use Case                   | Mechanism                                          |
| -------------------------- | -------------------------------------------------- |
| Admin-editable preferences | `admin.settingsSchema` + `ctx.kv` with `settings:` |
| Internal plugin state      | `ctx.kv` with `state:`                             |
| Collections of documents   | `ctx.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:

```typescript
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:

```typescript
"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`:

```typescript
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:

```sql
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.
