Skip to main content

Plugin blocks

Plugins can contribute editor blocks — paragraphs, embeds, layout primitives, complex content types. The block API is the same one theme blocks use — only the registration channel differs.

Plugin blocks vs theme blocks

Plugin blockTheme block
Registered viapluginApi.registerBlock(...)ThemeManifest.blocks: [...]
Available whenPlugin is enabledTheme is active
Reset onPlugin disable / re-registerTheme switch
Best forCross-theme reusable blocks (embeds, layout primitives)Theme-specific layout primitives

The flexweg-embeds MU plugin contributes the YouTube / Vimeo / Twitter / Spotify embed blocks via plugin registration — that's why those embeds work in every theme. The magazine theme contributes its own Most read widget as a theme block — it depends on magazine-specific CSS.

Block manifest

import type { BlockManifest } from "@flexweg/cms-runtime";
import { MyBlockIcon } from "./icon";
import { MyBlockNode } from "./node";
import { MyBlockInspector } from "./inspector";

export const myBlock: BlockManifest = {
id: "my-plugin/my-block",
titleKey: "myBlock.title",
namespace: "my-plugin",
icon: MyBlockIcon,
category: "media",
insert: (chain, ctx) => {
chain.focus().insertContent({ type: "myBlock" }).run();
},
isActive: (editor) => editor.isActive("myBlock"),
extensions: [MyBlockNode],
inspector: MyBlockInspector,
};

Then in the manifest:

register(api) {
api.registerBlock(myBlock);
api.addFilter<string>("post.html.body", transformBodyHtml);
}

Field-by-field

id

Namespaced as <plugin-id>/<block-name>. Must be unique across the entire block registry. The publisher uses the prefix to scope marker scans.

titleKey + namespace

i18n key for the inserter label. The namespace is the plugin's id (so it resolves against the plugin's i18n bundle).

titleKey: "myBlock.title", // → t("my-plugin:myBlock.title")
namespace: "my-plugin",

icon

Lucide-style React component. Renders at 18×18 in the inserter.

export function MyBlockIcon({ size = 18 }: { size?: number }) {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path d="M3 3h18v18H3z" />
</svg>
);
}

Or just import a Lucide icon:

import { Image } from "lucide-react";
icon: Image,

category

One of text | media | layout | embed | advanced. Drives grouping in the inserter UI.

insert(chain, ctx)

Called when the user picks the block in the inserter. chain is a Tiptap chain bound to the editor; ctx.pickMedia is provided by the host page for blocks that need a media picker.

insert: (chain) => {
chain.focus().insertContent({ type: "myBlock", attrs: { /* defaults */ } }).run();
}

For media-aware blocks:

insert: async (chain, ctx) => {
const media = await ctx.pickMedia({ multiple: false });
if (!media) return;
chain.focus().insertContent({
type: "myBlock",
attrs: { mediaId: media.id, alt: media.alt },
}).run();
}

isActive(editor)

Predicate driving the inspector's Block tab. The inspector iterates registered blocks and shows the first whose isActive returns true.

isActive: (editor) => editor.isActive("myBlock"),

extensions

Tiptap Node / Mark / Extension list. Picked up at editor mount — toggling a plugin during a session requires reopening the editor for new extensions to take effect (the inserter list refreshes immediately though).

inspector

Optional React component rendered in the inspector's Block tab when this block is active. Receives { attrs, updateAttrs, editor }.

export function MyBlockInspector({ attrs, updateAttrs }: BlockInspectorProps) {
return (
<div>
<label>
Caption
<input
value={attrs.caption}
onChange={(e) => updateAttrs({ caption: e.target.value })}
/>
</label>
</div>
);
}

The Tiptap Node

A typical plugin block is an atom node that stores config in attributes:

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

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

addAttributes() {
return {
mediaId: { default: "" },
caption: { default: "" },
};
},

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

renderHTML({ HTMLAttributes, node }) {
return [
"div",
{
"data-cms-block": "my-plugin/my-block",
"data-media-id": node.attrs.mediaId,
"data-caption": node.attrs.caption,
...HTMLAttributes,
},
];
},

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

The <div data-cms-block="..."> marker round-trips through tiptap-markdown (the post editor has html: true set). DOMPurify allows data-* attributes by default, so markers reach your post.html.body filter intact.

The publish-time render

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

export function transformBodyHtml(html: string, post: Post, ctx: PublishContext): string {
return html.replace(MARKER_RE, (match) => {
const attrs = parseAttrs(match);
const media = ctx.media.get(attrs["media-id"]);
if (!media) return ""; // graceful fallback
return `
<figure class="my-block">
<img src="${pickMediaUrl(mediaToView(media))}" alt="${attrs.caption}" />
<figcaption>${attrs.caption}</figcaption>
</figure>
`;
});
}

The filter receives (html, post, ctx). ctx has all the loaded data — posts, terms, media — so block renderers don't need Firestore reads.

NodeView

The NodeView is the editor preview. For plugin blocks, you'll often render a representation of what'll publish:

import { NodeViewWrapper } from "@tiptap/react";
import { useCmsData, mediaToView, pickMediaUrl } from "@flexweg/cms-runtime";

export function MyBlockNodeView({ node }: NodeViewProps) {
const { media } = useCmsData();
const view = mediaToView(media.find((m) => m.id === node.attrs.mediaId));
return (
<NodeViewWrapper>
{view ? (
<figure>
<img src={pickMediaUrl(view)} alt={node.attrs.caption} />
<figcaption>{node.attrs.caption}</figcaption>
</figure>
) : (
<div className="placeholder">No media selected</div>
)}
</NodeViewWrapper>
);
}

NodeViews can use admin context (Firestore data, hooks) because they run in the admin, not at publish time. This is one place where the static-rendering constraint doesn't apply.

Per-page detection (advanced)

If your block needs a runtime script (like Twitter's widgets.js), inject it conditionally — only on pages that have the block. Pattern from flexweg-embeds:

const detected = new Set<string>();

export function transformBodyHtml(html: string): string {
detected.clear(); // reset per-page
return html.replace(MARKER_RE, (match) => {
detected.add("my-block");
return renderBlock(match);
});
}

export function getDetectedBodyScripts(): string {
if (!detected.has("my-block")) return "";
return `<script src="https://my-block.runtime.js" defer></script>`;
}

// In manifest.register:
api.addFilter<string>("post.html.body", (html, post, ctx) => transformBodyHtml(html));
api.addFilter<string>("page.body.end", (current) => {
const scripts = getDetectedBodyScripts();
return scripts ? current + scripts : current;
});

The publisher runs post.html.body then page.body.end for the same page back-to-back, so the module-level detected Set stays consistent. Reset at the start of each transformBodyHtml call to avoid bleeding state across pages.

Continue