Update (May 2026): field-kit was merged into EmDash core as the first-party @emdash-cms/plugin-field-kit package via PR #702. The standalone ilicfilip/field-kit repo is archived — install the official package instead. See the epilogue for the migration details.

EmDash has 16 field types and most of them have good editing UIs in the admin. Text fields get text inputs, booleans get toggles, images get a media picker. But three types fall through to a bare <input>json, reference, and file. The reference and file gaps are bugs that should be fixed in core. But json is different.

A json field is a generic container. The right editing UI depends entirely on the data shape. Nutrition facts need a form with number inputs and unit labels. Ingredients need an ordered list with add/remove. A harvest calendar needs a row-by-column grid of checkboxes. There’s no single “json widget” that makes sense for all of these.

The only option was to write a native plugin with a custom React component — which is what I did for the harvest calendar on one of my sites. But that’s a high bar. Most site builders shouldn’t need to write React to get a form with a few sub-fields.

So I built field-kit, a plugin that provides composable field widgets configured entirely through seed options. No React code needed from site builders.

What it does

Field-kit replaces the default plain text input on json fields with configured, type-safe editing UIs. You install the plugin once, then configure widgets per field in your seed file:

{
  "slug": "nutrition",
  "type": "json",
  "widget": "field-kit:object-form",
  "options": {
    "fields": [
      { "key": "calories", "label": "Calories", "type": "number", "suffix": "kcal" },
      { "key": "protein", "label": "Protein", "type": "number", "suffix": "g" },
      { "key": "carbs", "label": "Carbohydrates", "type": "number", "suffix": "g" }
    ]
  }
}

That’s it. No component files, no build steps on the site side. The options object drives everything.

The four widgets

The first release ships four widgets — the building blocks that cover most structured data patterns.

object-form

An inline form for editing a flat JSON object. Think “a group of sub-fields that store as one JSON value.” Nutrition facts, coordinates, contact info, specs — anything where you’d otherwise burn a top-level field per attribute.

Stored value: { "calories": 250, "protein": 12.5 }

list

An ordered array editor with add, remove, and reorder. Like EmDash’s built-in repeater but with richer sub-field types. Each item is an object-form with a configurable summary template for the collapsed row label:

{
  "slug": "ingredients",
  "type": "json",
  "widget": "field-kit:list",
  "options": {
    "itemLabel": "Ingredient",
    "sortable": true,
    "summary": "{{name}} — {{amount}}",
    "fields": [
      { "key": "name", "label": "Name", "type": "text" },
      { "key": "amount", "label": "Amount", "type": "text" },
      { "key": "optional", "label": "Optional", "type": "boolean" }
    ]
  }
}

The summary template uses mustache-style {{key}} placeholders so editors can scan a long list without expanding every item.

grid

A two-dimensional matrix of rows and columns with configurable cell types. This is the generalized version of the harvest calendar I built earlier — but instead of being hardcoded for months and plant parts, it’s configured through seed options:

{
  "slug": "harvest",
  "type": "json",
  "widget": "field-kit:grid",
  "options": {
    "rows": [
      { "key": "jan", "label": "January" },
      { "key": "feb", "label": "February" }
    ],
    "columns": [
      { "key": "leaf", "label": "Leaf", "image": "/img/leaf.png" },
      { "key": "fruit", "label": "Fruit", "image": "/img/fruit.png" }
    ],
    "cell": "toggle"
  }
}

Cells can be toggle, text, number, or select. The grid handles both the new object format ({ jan: { leaf: true } }) and the legacy array format ({ jan: ["leaf"] }) on read, normalizing to the object shape on save.

tags

A free-form tag/chip input for string arrays. Unlike multiSelect which requires pre-defined options, tags lets editors type arbitrary values:

{
  "slug": "keywords",
  "type": "json",
  "widget": "field-kit:tags",
  "options": {
    "max": 10,
    "suggestions": ["organic", "seasonal", "dried"],
    "allowCustom": true,
    "transform": "lowercase"
  }
}

Enter or comma adds a tag, backspace removes the last one. Suggestions show as autocomplete, and when allowCustom is false it behaves like a tagging-flavored multi-select.

How it works under the hood

EmDash’s plugin system has a clean contract for custom field widgets. A plugin registers widget names and which field types they apply to, exports a component map, and EmDash does the wiring:

seed: "widget": "field-kit:object-form"
  → EmDash splits on ":"
  → plugin "field-kit", widget "object-form"
  → loads the plugin's adminEntry
  → looks up fields["object-form"]
  → renders the component with FieldWidgetProps

Every widget component receives the same props: value, onChange, label, options, and a few standard metadata fields. The options object is passed through verbatim from the seed — that’s the entire configuration surface.

The composability comes from a shared SubField component that both object-form and list use. It handles eight input types: text, number, boolean, select, textarea, date, color, and url. Adding a new sub-field type means adding one case there — every widget that uses sub-fields gets it automatically.

Data shape transparency

This was a design decision I felt strongly about: each widget stores clean, predictable JSON. An object-form stores { key: value }. A list stores [{ ... }]. Tags store ["tag1", "tag2"].

If you remove the plugin, your data is still valid JSON in the database. You lose the nice editing UI, but you don’t need a data migration. And swapping one widget for another on the same field works as long as the data shape is compatible.

The normalization layer handles the messy reality — wrong types, missing keys, legacy formats — so widgets always work with clean data internally.

What’s next

The plan is to add a second tier of widgets for enhanced single-value inputs: a color picker for string fields, a slider and rating widget for number/integer fields, and a date-range picker for json fields. These are less critical — the default inputs work, they’re just not great — but they round out the toolkit.

For now, the four core widgets cover the gap. If you’ve got a json field that editors are typing raw JSON into, this is the fix.

Update — merged into EmDash core

A few weeks after this post went up, field-kit landed in EmDash core as a first-party plugin. The maintainer confirmed the direction in discussion #571, and PR #702 shipped it as @emdash-cms/plugin-field-kit in the monorepo’s packages/plugins/ tree. Same four widgets, same seed-driven config, same data shapes — just a new package name and an official home.

The PR also widened FieldDescriptor.options from a strict Array<{ value, label }> to Array | Record<string, unknown> so plugin widgets can accept arbitrary config objects, not just enum choices. The array shape that select and multiSelect rely on still works unchanged. That was the one core change needed to make first-party plugins like this clean to write.

Migration is a package swap. The standalone repo used emdash-plugin-field-kit:

// before
import { fieldKitPlugin } from "emdash-plugin-field-kit";

The first-party package is @emdash-cms/plugin-field-kit:

// after
import { fieldKitPlugin } from "@emdash-cms/plugin-field-kit";

Seed configs ("widget": "field-kit:list", options, sub-field shapes) are unchanged. Stored JSON is unchanged. If you were on the standalone repo, swap the dependency and the import — that’s it.

The ilicfilip/field-kit repo is archived. It served its purpose as the working prototype that proved out the design; from here, development happens in the EmDash monorepo where it can move with the rest of the admin.

There’s also a visibleWhen follow-up already prototyped on the original repo as PR #2 — sub-fields in object-form and list can declare a rule like { field: "cooked", equals: true } and hide when a sibling value doesn’t match. Hidden fields stay in the DOM so values persist across toggles, and required is stripped on hidden inputs so HTML5 validation doesn’t block save. It’s flagged as a follow-up in the EmDash core PR and might be the next field-kit feature to land in core.