Skip to content

Block Kit

EmDash’s Block Kit lets sandboxed plugins describe their admin UI as JSON. The host renders the blocks — no plugin-supplied JavaScript ever runs in the browser.

  1. The user navigates to a plugin’s admin page.
  2. The admin sends a page_load interaction to the plugin’s admin route.
  3. The plugin returns a BlockResponse containing an array of blocks.
  4. The admin renders the blocks using the BlockRenderer component.
  5. When the user interacts (clicks a button, submits a form), the admin sends the interaction back to the plugin.
  6. The plugin returns new blocks, and the cycle repeats.
import { definePlugin } from "emdash";
import type { PluginContext } from "emdash";
interface BlockInteraction {
type: "page_load" | "block_action" | "form_submit";
page?: string;
action_id?: string;
values?: Record<string, unknown>;
}
export default definePlugin({
routes: {
admin: {
handler: async (routeCtx, ctx: PluginContext) => {
const interaction = routeCtx.input as BlockInteraction;
if (interaction.type === "page_load") {
return {
blocks: [
{ type: "header", text: "My Plugin Settings" },
{
type: "form",
block_id: "settings",
fields: [
{ type: "text_input", action_id: "api_url", label: "API URL" },
{ type: "toggle", action_id: "enabled", label: "Enabled", initial_value: true },
],
submit: { label: "Save", action_id: "save" },
},
],
};
}
if (interaction.type === "form_submit" && interaction.action_id === "save") {
await ctx.kv.set("settings", interaction.values);
return {
blocks: [/* ... updated blocks ... */],
toast: { message: "Settings saved", type: "success" },
};
}
return { blocks: [] };
},
},
},
});

The standard-format route handler takes two arguments: routeCtx (with input, request, requestMeta) and ctx (the PluginContext).

TypeDescription
headerLarge bold heading
sectionText with optional accessory element
dividerHorizontal rule
fieldsTwo-column label/value grid
tableData table with formatting, sorting, pagination
actionsHorizontal row of buttons and controls
statsDashboard metric cards with trend indicators
formInput fields with conditional visibility and submit
imageBlock-level image with caption
contextSmall muted help text
columns2–3 column layout with nested blocks
emptyEmpty-state placeholder with icon, title, description, optional command line, and action buttons
accordionCollapsible section wrapping nested blocks
TypeDescription
buttonAction button with optional confirmation dialog
text_inputSingle-line or multiline text input
number_inputNumeric input with min/max
selectDropdown select
toggleOn/off switch
secret_inputMasked input for API keys and tokens

The @emdash-cms/blocks package exports builder helpers for cleaner code:

import { blocks, elements } from "@emdash-cms/blocks";
const { header, form, section, stats } = blocks;
const { textInput, toggle, select, button } = elements;
return {
blocks: [
header("SEO Settings"),
form({
blockId: "settings",
fields: [
textInput("site_title", "Site Title", { initialValue: "My Site" }),
toggle("generate_sitemap", "Generate Sitemap", { initialValue: true }),
select("robots", "Default Robots", [
{ label: "Index, Follow", value: "index,follow" },
{ label: "No Index", value: "noindex,follow" },
]),
],
submit: { label: "Save", actionId: "save" },
}),
],
};

Form fields can be conditionally shown based on other field values:

{
"type": "toggle",
"action_id": "auth_enabled",
"label": "Enable Authentication"
}
{
"type": "secret_input",
"action_id": "api_key",
"label": "API Key",
"condition": { "field": "auth_enabled", "eq": true }
}

The api_key field only appears when auth_enabled is toggled on. Conditions are evaluated client-side with no round-trip.

Use the Block Playground to interactively build and test block layouts.