Skip to main content

Theme blocks

Themes can contribute editor blocks — atom blocks that render at publish time as full sections of HTML (heroes, post grids, CTAs, sidebars). Theme blocks are scoped to the active theme: switching themes deregisters the old theme's blocks and registers the new one's.

This is what enables the magazine theme's Most read widget, the corporate theme's Contact form, etc. Custom themes can do the same.

When to use a theme block vs a core block

PurposeUse
Layout primitive useful across themes (paragraph, heading, image, columns)Core block (registered in src/core/coreBlocks.ts)
Layout primitive that uses theme-specific CSS or structureTheme block (registered in your theme's manifest)
Reusable content type with config (testimonial, pricing table)Theme block if theme-specific styling, plugin block otherwise

Theme blocks let you ship content primitives that depend on your theme's CSS classes, design tokens, or markup conventions — without polluting other themes' editors.

Block manifest

import type { BlockManifest } from "@flexweg/cms-runtime";
import { TestimonialIcon } from "./icon";
import { TestimonialNode } from "./node";
import { TestimonialInspector } from "./inspector";

export const testimonialBlock: BlockManifest = {
id: "my-theme/testimonial",
titleKey: "testimonial.title",
namespace: "theme-my-theme", // matches the i18n namespace
icon: TestimonialIcon,
category: "layout",
insert: (chain) => {
chain.focus().insertContent({ type: "testimonial" }).run();
},
isActive: (editor) => editor.isActive("testimonial"),
extensions: [TestimonialNode],
inspector: TestimonialInspector,
};

Fields:

  • id — namespaced like <theme-id>/<block-name>. Must be unique across all blocks in the registry.
  • titleKey — i18n key for the inserter label
  • namespace — i18n namespace; theme blocks use theme-<id> so they share the theme's i18n bundle
  • icon — Lucide-style React component shown in the inserter
  • categorytext | media | layout | embed | advanced — controls grouping in the inserter
  • insert(chain, ctx) — called when the user picks the block. ctx.pickMedia is provided by the host page for blocks that need a media picker
  • isActive(editor) — predicate for the inspector's Block tab. First manifest whose predicate matches wins.
  • extensions — Tiptap Node / Mark / Extension list. Picked up at editor mount.
  • inspector — React component rendered in the inspector's Block tab when the block is active. Receives { attrs, updateAttrs, editor }.

The Tiptap Node

Theme blocks are typically atom nodes — single editable units that store config in attributes:

import { Node } from "@tiptap/core";
import { ReactNodeViewRenderer } from "@tiptap/react";
import { TestimonialNodeView } from "./NodeView";

export const TestimonialNode = Node.create({
name: "testimonial",
group: "block",
atom: true,
draggable: true,

addAttributes() {
return {
quote: { default: "" },
author: { default: "" },
authorTitle: { default: "" },
};
},

parseHTML() {
return [{ tag: 'div[data-cms-block="my-theme/testimonial"]' }];
},

renderHTML({ HTMLAttributes, node }) {
return [
"div",
{
"data-cms-block": "my-theme/testimonial",
"data-quote": node.attrs.quote,
"data-author": node.attrs.author,
"data-author-title": node.attrs.authorTitle,
...HTMLAttributes,
},
];
},

addNodeView() {
return ReactNodeViewRenderer(TestimonialNodeView);
},
});

Three responsibilities:

  1. addAttributes — declare the config the block stores. These survive the Markdown round-trip via data-* attributes.
  2. parseHTML / renderHTML — emit a <div data-cms-block="..."> marker that round-trips through tiptap-markdown (which has html: true for the post editor).
  3. addNodeView — render the editor preview. This is what the user sees while editing.

The publish-time render

The Tiptap renderHTML emits a marker; the publisher's filter chain swaps it for real HTML.

In manifest.ts:

import { transformBodyHtml } from "./blocks/transforms";

export const manifest: ThemeManifest = {
// …
blocks: [testimonialBlock, /* … */],
register(api) {
api.addFilter<string>("post.html.body", (html, ctx) => {
return transformBodyHtml(html, ctx);
});
},
};

In blocks/transforms.ts:

const MARKER_RE = /<div\s+data-cms-block="my-theme\/([^"]+)"[^>]*>.*?<\/div>/g;

export function transformBodyHtml(html: string, ctx: PublishContext): string {
return html.replace(MARKER_RE, (_match, blockName, fullMatch) => {
const attrs = parseAttrs(fullMatch);
if (blockName === "testimonial") return renderTestimonial(attrs);
if (blockName === "pricing-table") return renderPricingTable(attrs, ctx);
return ""; // strip unknown markers
});
}

function renderTestimonial(attrs: Record<string, string>): string {
return `
<blockquote class="testimonial">
<p>${escapeHtml(attrs.quote)}</p>
<cite>${escapeHtml(attrs.author)}, ${escapeHtml(attrs["author-title"])}</cite>
</blockquote>
`;
}

The filter receives the post body HTML (already DOMPurified) and the publish context. Replace markers with real HTML; return the result.

<div data-cms-block="..."> survives DOMPurify because data-* attributes are in the default ALLOWED_ATTR list. So markers reach your filter intact.

The inspector

The right-side panel's Block tab renders your inspector when the block is active. It's a React component receiving { attrs, updateAttrs, editor }:

export function TestimonialInspector({ attrs, updateAttrs }: BlockInspectorProps) {
return (
<div className="space-y-3">
<label>
Quote
<textarea
value={attrs.quote}
onChange={(e) => updateAttrs({ quote: e.target.value })}
/>
</label>
<label>
Author
<input
value={attrs.author}
onChange={(e) => updateAttrs({ author: e.target.value })}
/>
</label>
<label>
Author title
<input
value={attrs.authorTitle}
onChange={(e) => updateAttrs({ authorTitle: e.target.value })}
/>
</label>
</div>
);
}

updateAttrs(partial) patches the active block's attributes — Tiptap's updateAttributes under the hood.

NodeView preview

The NodeView is what the user sees in the editor. It can be a pure rendering of the published HTML, or a richer editor experience:

import { NodeViewWrapper } from "@tiptap/react";

export function TestimonialNodeView({ node, updateAttributes }: NodeViewProps) {
return (
<NodeViewWrapper className="testimonial-editor">
<textarea
value={node.attrs.quote}
placeholder="Quote..."
onChange={(e) => updateAttributes({ quote: e.target.value })}
/>
<input
value={node.attrs.author}
placeholder="Author"
onChange={(e) => updateAttributes({ author: e.target.value })}
/>
</NodeViewWrapper>
);
}

Or for a simpler experience, just render a static preview:

export function TestimonialNodeView({ node }: NodeViewProps) {
return (
<NodeViewWrapper>
<blockquote className="testimonial">
<p>{node.attrs.quote || "(no quote)"}</p>
<cite>{node.attrs.author}</cite>
</blockquote>
</NodeViewWrapper>
);
}

The block's actual editing happens in the inspector. The NodeView just shows what it'll look like.

Reading PublishContext from a block renderer

The post.html.body filter receives (html, ctx: PublishContext). So your renderer can resolve dynamic content:

function renderRecentPosts(attrs: Record<string, string>, ctx: PublishContext): string {
const limit = parseInt(attrs.limit || "5", 10);
const recent = ctx.posts.slice(0, limit);
return `
<ul class="recent-posts">
${recent.map((p) => `<li><a href="${buildPostUrl(p, ctx.terms, ctx.settings)}">${p.title}</a></li>`).join("")}
</ul>
`;
}

Read posts, terms, media from ctx. Resolve URLs via buildPostUrl / buildTermUrl. Resolve images via pickMediaUrl. Don't fetch from Firestore directly — the publisher already loaded everything.

When to use NodeView with React vs static HTML

React NodeViewStatic HTML preview
Inline editing in the canvas (textarea, input)YesNo
Cost on the editor performanceHigher (each NodeView mounts its own React tree)Lower
Code complexityHigherLower

For 90% of theme blocks, a static preview rendering the published HTML is enough — admins do the editing in the inspector. Reserve inline-editing NodeViews for blocks where typing-in-place is critical (e.g. captions on images).

Continue