How to build a custom block in Slim Minima

A block is one file: a schema, some fields, and a render function. Register it once and you get a palette entry, a settings form, validation, and an API for free.

A single bright building block, representing a custom content block added to the CMS

Most CMS extension stories start with a plugin SDK, a manifest format, and a lifecycle you have to learn. Slim Minima skips all of that. A block is a single TypeScript file. You write a schema, list the fields you want in the editor, and write a function that returns JSX. Then you add one line to a registry. That is the whole job.

If you can write a React component and a Zod schema, you can add a block. The CMS wires up the rest.

This is a tutorial. I'll carry one example all the way through: a "stat callout" block that shows a number, a label, and a short caption. By the end you'll have a working block in the editor, validated, exposed over the API, and rendered on the public site. I'll also cover the optional pieces (server data, an editor preview, container zones, sanitized HTML, and the markdown emitter) and the honest limits.

The short version

A block lives at src/blocks/<name>/index.tsx and default-exports defineBlock<Props>({...}). You give it a type, a label, a Zod schema, some defaults, a fields array, and a Render component. You import it into src/blocks/registry.ts and push it onto the ALL array. After that, the palette, the auto-generated settings form, Zod validation, the REST API, and the public renderer all pick it up with no further wiring.

That's it. The rest of this article is the detail.

Step 1: write the block file

Create src/blocks/stat-callout/index.tsx. The file does two things: it defines a Zod schema that describes the block's data, and it passes that plus a Render component into defineBlock.

Here is the full example.

import { z } from "zod";
import { defineBlock } from "@/blocks/define";

const schema = z.object({
  heading: z.string().default("By the numbers"),
  intro: z.string().default(""),
  layout: z.enum(["row", "stack"]).default("row"),
  showDividers: z.boolean().default(true),
  stats: z
    .array(
      z.object({
        value: z.string(),
        label: z.string(),
        caption: z.string().default(""),
      }),
    )
    .default([]),
});

type Props = z.infer<typeof schema>;

export default defineBlock<Props>({
  type: "stat-callout",
  label: "Stat callout",
  description: "A row of headline numbers with labels and short captions.",
  icon: "BarChart",
  schema,
  defaults: {
    heading: "By the numbers",
    intro: "",
    layout: "row",
    showDividers: true,
    stats: [
      { value: "10,000+", label: "monthly visitors", caption: "on the free tier" },
      { value: "0.5s", label: "cold start", caption: "hidden by the cache" },
    ],
  },
  fields: [
    { name: "heading", kind: "text", label: "Heading" },
    { name: "intro", kind: "textarea", label: "Intro text" },
    {
      name: "layout",
      kind: "select",
      label: "Layout",
      options: [
        { value: "row", label: "Horizontal row" },
        { value: "stack", label: "Stacked" },
      ],
    },
    { name: "showDividers", kind: "toggle", label: "Show dividers" },
    {
      name: "stats",
      kind: "list",
      label: "Stats",
      itemFields: [
        { name: "value", kind: "text", label: "Number" },
        { name: "label", kind: "text", label: "Label" },
        { name: "caption", kind: "text", label: "Caption" },
      ],
    },
  ],
  Render: ({ heading, intro, layout, showDividers, stats }) => (
    <section className="cms-stat-callout" data-layout={layout}>
      {heading ? <h2 className="cms-stat-callout__heading">{heading}</h2> : null}
      {intro ? <p className="cms-stat-callout__intro">{intro}</p> : null}
      <div
        className="cms-stat-callout__grid"
        data-dividers={showDividers ? "on" : "off"}
      >
        {stats.map((s, i) => (
          <div className="cms-stat-callout__item" key={i}>
            <span className="cms-stat-callout__value">{s.value}</span>
            <span className="cms-stat-callout__label">{s.label}</span>
            {s.caption ? (
              <span className="cms-stat-callout__caption">{s.caption}</span>
            ) : null}
          </div>
        ))}
      </div>
    </section>
  ),
});

A few things to notice about the keys.

  • type is the stable string id. It goes in the database and the API, so don't rename it casually.
  • label, description, and icon are what the editor shows in the block palette.
  • schema is a Zod object. It is the contract for the block's data. Validation comes from here.
  • defaults is what gets inserted when an editor adds the block. Give every block a sensible starting state so a new block looks right immediately.
  • fields drives the settings form. Each entry maps a prop to an input kind.

Field kinds you can use

The example uses six of them. The full set available is: text, textarea, number, toggle, select (with options), image, page (a link to another page), richtext, and list (a repeatable group with nested itemFields). The list kind is how the stat callout gets its repeatable rows: you nest plain fields inside it and the editor renders add, remove, and reorder controls.

Match the field kind to the schema type. A toggle pairs with z.boolean(), a select with a z.enum(), a list with a z.array(). If they disagree, validation will reject saves that the form produced, which is a confusing bug to chase.

The Render rules

Render is the only part that ships to the public site, so it has rules.

  • It must be pure and synchronous. No hooks, no useState, no useEffect, no await. If you need data from the server, that is what getData is for (below).
  • It has to work in both server and client trees, so don't reach for browser-only APIs at the top level.
  • Style it with cms-* classes and the theme CSS variables (--bg, --surface, --text, --accent, --radius), defined in src/app/globals.css. That keeps your block on-theme when someone restyles the site by editing variables.
  • Do not put a border or outline on cards. Slim Minima's design is background-only: a card is defined by its surface color, not an outline. So your .cms-stat-callout__item gets a background: var(--surface) and a border-radius: var(--radius), never a border.

Step 2: register it

Open src/blocks/registry.ts. It is the single source of truth for which blocks exist. Import your block and add it to the ALL array.

import statCallout from "@/blocks/stat-callout";
// ...other imports

export const ALL = [
  // ...built-in blocks
  statCallout,
];

One import, one array entry. That is the entire registration step. There is no separate manifest to keep in sync and no build step to run.

What you get for free

Once the block is in ALL, four things happen without any extra code.

  1. It appears in the editor's block palette, using your label, description, and icon.
  2. The settings form is generated from your fields array, so editors get real inputs (a select dropdown, a toggle, an add/remove list) instead of raw JSON.
  3. Every save is validated against your Zod schema. Bad data never reaches the database.
  4. The block is exposed through the REST API and rendered on the public site by your Render function. It also shows up in the /api/v1/blocks catalog.

This is the payoff of the single-registry design. You describe the block once and the palette, the form, the validation, the API, and the renderer all read from that one description.

The optional pieces

The example above is a complete, working block. These additions are for when you need more.

getData for server-side data

If your block needs data that isn't entered in the editor (a list of recent posts, a count from the database, a value from an external API), add an async getData. It runs on the server before render, and its result is passed into Render. This keeps Render pure: the async work happens in getData, the synchronous JSX happens in Render.

getData: async (props) => {
  const live = await countLivePosts();
  return { ...props, liveCount: live };
},

Preview for the editor canvas

Render is what shows on the live site and, by default, in the editor canvas too. If your block depends on getData (which doesn't run inside the editor) or just looks awkward in the editing context, supply an optional Preview component as the editor-canvas fallback. The public site always uses Render.

zoneCount for container blocks

If your block should hold other blocks (like the built-in columns block), set zoneCount to the number of nested drop zones it has. The editor then renders those zones and lets people drag blocks into them. A two-up layout block would set zoneCount: 2.

rawHtmlFields for sanitized HTML

By default, string props are rendered as text, which is correct and safe. If a field is meant to hold HTML, list its name in rawHtmlFields. Slim Minima runs that field through sanitize-html before render, stripping dangerous tags while keeping the markup you want. Never bypass this and inject HTML yourself.

A markdown emitter for /llms.txt

Slim Minima is built to be readable by language models: there's an /llms.txt index, a full-site /llms-full.txt, and per-page markdown at /<slug>.md. To control how your block appears in those, add an emitter for it in src/lib/block-text.ts. The emitter takes the block's props and returns plain markdown. For the stat callout, you'd emit the heading and a line per stat.

"stat-callout": (props) =>
  [
    props.heading ? `## ${props.heading}` : "",
    ...props.stats.map((s) => `- **${s.value}** ${s.label} (${s.caption})`),
  ]
    .filter(Boolean)
    .join("\n"),

Without an emitter you still get reasonable output: a fallback extracts the non-URL string props from the block. The emitter just makes it clean and structured, which matters if you care about how models and the SEO tooling read your pages. If you want the wider picture of how this works, I wrote about it in does free hosting hurt your SEO.

You can build this with an AI agent

The block format is deliberately small and consistent, which makes it a good fit for AI-assisted coding. Point Claude Code, Codex, or Cursor at an existing block in src/blocks, describe the one you want, and it will produce a file in the same shape. Because everything funnels through the schema and the registry, the agent has a narrow, well-defined target.

I usually write a new block by describing it in plain English and then editing the result. This is the same workflow I describe in building a client-ready website with Slim Minima.

The honest limit

Blocks are code. That is the tradeoff to be clear about.

A developer adds new block types. A non-technical site owner cannot, because creating a block type means writing a TypeScript file and editing the registry. What a non-developer gets is the use of every block that already exists: they add a stat callout to a page, fill in the numbers, reorder the rows, and publish, all from the admin panel without touching code.

So the division is clean. Developers define the toolbox; everyone else builds pages with it. This is the same code-not-plugins philosophy that runs through the whole project, which I explain in why I built Slim Minima. There is no plugin marketplace and no one-click install. You extend by writing code, and you own that code under the MIT license.

Frequently asked questions

What is the minimum needed to create a Slim Minima block? One file at src/blocks/<name>/index.tsx that default-exports defineBlock with a type, label, Zod schema, defaults, a fields array, and a synchronous Render component, plus one line adding it to the ALL array in src/blocks/registry.ts. Nothing else is required.

Can a non-developer create new block types in Slim Minima? No. Creating a new block type means writing TypeScript and editing the registry, so it is a developer task. Non-developers can use any block that already exists, adding it to pages and editing its content from the admin panel without code.

Why must the Render component be pure and synchronous? Because it runs on both the server and in client trees and is the function the public site uses to draw the block. Hooks or async work would break that. If you need server data, load it in the optional async getData, which passes its result into Render.

How do I add fields like an image picker or a repeatable list? List them in the fields array. The available kinds are text, textarea, number, toggle, select (with options), image, page, richtext, and list. The list kind takes nested itemFields and gives editors add, remove, and reorder controls for repeating rows.

How does a custom block show up in /llms.txt and per-page markdown? Add an emitter for the block in src/lib/block-text.ts that returns plain markdown from the block's props. If you skip it, a fallback extracts the non-URL string props automatically, so the block still appears, just less neatly structured.

Do I need to redeploy after editing a block's content? Editing content (the values an editor types in) takes effect through the CMS without a redeploy. Changing the block's code (its schema, fields, or Render) is a code change, so it ships on the next build.

Related reading

My verdict

If you write Next.js, building a block is about as light as extending a CMS gets: one file, one array entry, and you inherit the palette, the form, validation, and the API. Start by copying a built-in block that resembles what you want and adjust it. Use getData only when you genuinely need server data, and keep Render pure.

If you are not a developer, this article is the wrong tool for you: stick to the blocks already in the editor, which cover most marketing and content sites without anyone touching code.

#Slim Minima blocks#Slim Minima defineBlock#custom CMS block Next.js#Slim Minima tutorial