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
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.
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:
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.