diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs
index 1e19ff4c9..5c9053610 100644
--- a/docs/astro.config.mjs
+++ b/docs/astro.config.mjs
@@ -88,6 +88,7 @@ export default defineConfig({
{ label: "Plugin Settings", slug: "plugins/settings" },
{ label: "Admin UI Extensions", slug: "plugins/admin-ui" },
{ label: "Block Kit", slug: "plugins/block-kit" },
+ { label: "Field Kit", slug: "plugins/field-kit" },
{ label: "API Routes", slug: "plugins/api-routes" },
{ label: "Sandbox & Security", slug: "plugins/sandbox" },
{ label: "Publishing Plugins", slug: "plugins/publishing" },
diff --git a/docs/src/content/docs/plugins/field-kit.mdx b/docs/src/content/docs/plugins/field-kit.mdx
new file mode 100644
index 000000000..cc5a744b5
--- /dev/null
+++ b/docs/src/content/docs/plugins/field-kit.mdx
@@ -0,0 +1,243 @@
+---
+title: Field Kit
+description: Composable field widgets for json fields, configured through seed options.
+---
+
+import { Aside } from "@astrojs/starlight/components";
+
+EmDash's `json` field type stores arbitrary structured data, but the default editor is a single-line text input where you have to type raw JSON by hand. **Field Kit** is a first-party plugin that ships four composable widgets for `json` fields, configured entirely through seed `options` — no React required from site builders.
+
+
+
+## Installation
+
+```bash
+npm i @emdash-cms/plugin-field-kit
+```
+
+Register the plugin in `astro.config.mjs`:
+
+```typescript
+import { defineConfig } from "astro/config";
+import emdash from "emdash";
+import { fieldKitPlugin } from "@emdash-cms/plugin-field-kit";
+
+export default defineConfig({
+ integrations: [
+ emdash({
+ plugins: [fieldKitPlugin()],
+ }),
+ ],
+});
+```
+
+Then attach a widget to any `json` field by setting `widget` to `field-kit:`:
+
+```json
+{
+ "slug": "ingredients",
+ "type": "json",
+ "widget": "field-kit:list",
+ "options": { "fields": [...] }
+}
+```
+
+## Widgets
+
+| Widget | Use for | Stored value |
+|--------|---------|--------------|
+| `object-form` | Inline form for flat JSON objects | `{ key: value, ... }` |
+| `list` | Ordered array editor with add / remove / reorder | `[{ ... }, ...]` |
+| `grid` | Rows × columns matrix | `{ rowKey: { colKey: value } }` |
+| `tags` | Free-form chip/tag input | `["tag1", "tag2"]` |
+
+If a widget is missing its required `options` (e.g. `fields` for `object-form`/`list`, or `rows`/`columns` for `grid`), the editor renders an inline "Widget misconfigured" warning instead of a broken input — useful while iterating on seed schemas.
+
+### object-form
+
+Renders a group of typed sub-fields that store as a single JSON object. Good for fixed-shape structured data like nutrition facts or contact info.
+
+```json
+{
+ "slug": "nutrition",
+ "type": "json",
+ "widget": "field-kit:object-form",
+ "options": {
+ "collapsed": false,
+ "fields": [
+ { "key": "calories", "label": "Calories", "type": "number", "suffix": "kcal" },
+ { "key": "protein", "label": "Protein", "type": "number", "suffix": "g" },
+ { "key": "fat", "label": "Fat", "type": "number", "suffix": "g" },
+ { "key": "carbs", "label": "Carbs", "type": "number", "suffix": "g" }
+ ]
+ }
+}
+```
+
+Stored value: `{ "calories": 250, "protein": 12.5, "fat": 8, "carbs": 30 }`.
+
+| Option | Type | Default | Description |
+|--------|------|---------|-------------|
+| `fields` | `SubFieldDef[]` | _(required)_ | Sub-field definitions — see [Sub-fields](#sub-fields). |
+| `collapsed` | `boolean` | `false` | Render the group collapsed by default. |
+| `helpText` | `string` | — | Help text shown below the widget. |
+
+### list
+
+An ordered array editor with add, remove, and reorder controls. Each row is a JSON object whose shape is defined by `fields`. The row header shows a summary rendered from a Mustache-style template.
+
+```json
+{
+ "slug": "ingredients",
+ "type": "json",
+ "widget": "field-kit:list",
+ "options": {
+ "itemLabel": "Ingredient",
+ "min": 1,
+ "max": 50,
+ "sortable": true,
+ "summary": "{{name}} — {{amount}}",
+ "fields": [
+ { "key": "name", "label": "Name", "type": "text", "required": true },
+ { "key": "amount", "label": "Amount", "type": "text" },
+ { "key": "optional", "label": "Optional", "type": "boolean" }
+ ]
+ }
+}
+```
+
+Stored value:
+
+```json
+[
+ { "name": "Flour", "amount": "500g", "optional": false },
+ { "name": "Butter", "amount": "200g", "optional": false }
+]
+```
+
+| Option | Type | Default | Description |
+|--------|------|---------|-------------|
+| `fields` | `SubFieldDef[]` | _(required)_ | Sub-field definitions for each row. |
+| `itemLabel` | `string` | `"Item"` | Singular label for a row (used in the "Add" button and fallback row titles). |
+| `min` | `number` | — | Minimum number of items. Below this, the remove button hides. |
+| `max` | `number` | — | Maximum number of items. At this count, the add button hides. |
+| `sortable` | `boolean` | `true` | Show up/down reorder buttons. |
+| `summary` | `string` | — | Mustache template rendered as the collapsed-row title. See [Summary templates](#summary-templates). |
+| `helpText` | `string` | — | Help text shown below the widget. |
+
+### grid
+
+A two-dimensional matrix of rows × columns. Each cell can be a toggle, text input, number input, or select. Useful for matrices like seasonal availability, price tables, or feature comparisons.
+
+```json
+{
+ "slug": "availability",
+ "type": "json",
+ "widget": "field-kit:grid",
+ "options": {
+ "cell": "toggle",
+ "rows": [
+ { "key": "berries", "label": "Berries" },
+ { "key": "stoneFruit", "label": "Stone fruit" },
+ { "key": "citrus", "label": "Citrus" }
+ ],
+ "columns": [
+ { "key": "spring", "label": "Spring" },
+ { "key": "summer", "label": "Summer" },
+ { "key": "autumn", "label": "Autumn" },
+ { "key": "winter", "label": "Winter" }
+ ]
+ }
+}
+```
+
+Stored value:
+
+```json
+{
+ "berries": { "spring": false, "summer": true, "autumn": false, "winter": false },
+ "stoneFruit": { "spring": false, "summer": true, "autumn": true, "winter": false },
+ "citrus": { "spring": false, "summer": false, "autumn": true, "winter": true }
+}
+```
+
+| Option | Type | Default | Description |
+|--------|------|---------|-------------|
+| `rows` | `GridAxisDef[]` | _(required)_ | Row definitions: `{ key, label, image? }`. |
+| `columns` | `GridAxisDef[]` | _(required)_ | Column definitions: `{ key, label, image? }`. |
+| `cell` | `"toggle"` \| `"text"` \| `"number"` \| `"select"` | `"toggle"` | Cell input type, applied uniformly to every cell. |
+| `cellOptions` | `string[]` \| `Array<{ label, value }>` | `[]` | Required when `cell` is `"select"`. |
+| `helpText` | `string` | — | Help text shown below the widget. |
+
+### tags
+
+A chip-style input for arrays of strings. Supports a fixed `suggestions` list, free-form custom values (toggleable), case transforms, and an optional `max`.
+
+```json
+{
+ "slug": "keywords",
+ "type": "json",
+ "widget": "field-kit:tags",
+ "options": {
+ "placeholder": "Add a keyword…",
+ "max": 10,
+ "transform": "lowercase",
+ "allowCustom": true,
+ "suggestions": ["vegan", "vegetarian", "gluten-free", "dairy-free", "nut-free"]
+ }
+}
+```
+
+Stored value: `["vegan", "gluten-free"]`.
+
+Press Enter or `,` to commit a tag. Backspace on an empty input removes the last tag. Duplicate tags are silently ignored.
+
+| Option | Type | Default | Description |
+|--------|------|---------|-------------|
+| `placeholder` | `string` | `"Add..."` | Input placeholder shown when no tags are present. |
+| `max` | `number` | — | Maximum number of tags. The input hides at the limit. |
+| `suggestions` | `string[]` | `[]` | Autocomplete suggestions surfaced via a `