DocsStart hereWrite your first block

A block is two things: a schema that says what it stores, and a component that renders it. Nothing else. You write it in your own repo, in TypeScript, through the same `defineBlock` API the built-in blocks use — if a core block can do something yours cannot, that is a bug in the API.

The whole thing

app/blocks.tsx
import { defineBlock, registerBlocks, CORE_BLOCK_DEFS, Editable, useEdit, inlineFormat, Grid, TextInput } from "@vinumcms/react";import { safeHref, type Block } from "@vinumcms/core"; type CalloutBlock = Block & { type: "callout"; body: string; href: string }; const callout = defineBlock<CalloutBlock>({  type: "callout",  label: "Callout",  group: "This site",  create: () => ({ body: "Something worth saying", href: "" }),  sanitize: (raw) => ({    body: typeof raw.body === "string" ? raw.body : "",    href: safeHref(raw.href),  }),  Settings: ({ block, patch }) => (    <Grid cols={1}>      <TextInput label="Link" value={block.href} onChange={(v) => patch({ href: v })} />    </Grid>  ),  Render: ({ block, set }) => (    <aside className="callout">      <Txt value={block.body} onChange={(v) => set({ body: v })} />    </aside>  ),}); export const BLOCKS = [...CORE_BLOCK_DEFS, callout];registerBlocks(BLOCKS);

That is a complete, editable block: it appears in the palette, saves, sanitises, and its text is typed on the page itself.

Editable text

`Editable` is what makes a field editable in the canvas. It is opt-in per field — a field you render as plain text can only be reached through the settings panel, which is usually not what you want.

the Txt helper, used above
function Txt({ value, onChange }: { value: string; onChange: (v: string) => void }) {  const ed = useEdit();  if (!ed.edit) return <>{inlineFormat(value)}</>;  return <Editable value={value} onChange={onChange} />;}

`set` is a no-op in view mode, so wire it unconditionally. `inlineFormat` renders the `bold italic highlight link` syntax — forget it and visitors see literal asterisks.

Four rules that are not style preferences

Register in a module `root.tsx` imports. Block definitions contain functions, so they cannot travel through loader data, and the editor renders blocks in the browser. Register only on the server and the admin palette is empty.

`sanitize` is a trust boundary. It receives whatever JSON the editor posted — which is whatever anyone with an editor account can send. Coerce every field, never throw, never pass a value through untouched. Run URLs through `safeHref` and `safeSrc`.

Use a type alias, not an interface. `type CalloutBlock = Block & {…}` — an interface gets no implicit index signature and will not be assignable to the open `Block` type.

Removing a block type deletes content. `sanitizeBlocks` drops blocks whose type is not registered, so pulling one out of the palette strips it from every page on the next save. Treat it as a content migration.

Free controls

Declare `variants` and the admin renders the pickers, the sanitiser validates them, and every theme already styles the result — with no change to your renderer:

one line
variants: ["align", "tone"],

`align` puts the text left, centre or right. `tone` decides which background and foreground pair the block sits on. You opt into the vocabulary; you cannot add to it, which is what stops a block producing a combination no theme has defined.

Where sanitize earns its keep

It runs on every save, so it doubles as your migration path: add a field with a fallback and old blocks pick it up the next time anyone saves them. A block whose content outlives its code has to read defensively — this site took its own production deploy down once by rendering a field that older stored records did not have.

Next

The full reference — repeated fields with add and remove in place, settings scoped to one element, the media picker, and the email block API — is in `docs/BLOCKS.md` in the repository.