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
| Purpose | Use |
|---|---|
| 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 structure | Theme 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 labelnamespace— i18n namespace; theme blocks usetheme-<id>so they share the theme's i18n bundleicon— Lucide-style React component shown in the insertercategory—text | media | layout | embed | advanced— controls grouping in the inserterinsert(chain, ctx)— called when the user picks the block.ctx.pickMediais provided by the host page for blocks that need a media pickerisActive(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:
addAttributes— declare the config the block stores. These survive the Markdown round-trip viadata-*attributes.parseHTML/renderHTML— emit a<div data-cms-block="...">marker that round-trips throughtiptap-markdown(which hashtml: truefor the post editor).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 NodeView | Static HTML preview | |
|---|---|---|
| Inline editing in the canvas (textarea, input) | Yes | No |
| Cost on the editor performance | Higher (each NodeView mounts its own React tree) | Lower |
| Code complexity | Higher | Lower |
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
- Theme manifest
- Templates and props
- Editor → Theme blocks — visitor-facing usage
- Plugin blocks — same API for plugins