Static Renderer
Render saved editor content as styled, read-only HTML or React (from editor.getHTML() or editor.getJSON()).
StaticRenderer displays content produced by @editorcn/editor or
@editorcn/block-editor as read-only, fully styled output for blog
posts, preview panes, emails, or chat message history.
One component accepts two kinds of saved content:
- an HTML string from
editor.getHTML(), or - a ProseMirror JSON document from
editor.getJSON()(or parsed on your backend / received via an API).
It ships a dedicated stylesheet targeting the exact structure the editors
serialize, so lists, headings, code blocks, tables, task lists, and embeds all
render correctly out of the box. No markdown/typeset.css conversions needed.
Writing on the web
Why prose matters
Your saved document can be rendered anywhere — a blog post, an email, a chat message, a print view. The StaticRenderer styles the exact HTML the editor serializes, so it keeps your typesetting without needing a markdown pipeline.
Every image node is intercepted in the JSON pane through
nodeMapping and rendered by a component instead of plain markup.
- Headings and paragraphs
- Lists, nesting, and inline
code - Links like the project site
- Write in the editor
- Save
editor.getHTML() - Render it with
StaticRenderer— read only
-
Nested lists
- … nest just like you would in the editor
Good typography is invisible, and that visible page is all that matters.
Block elements and inline styles pass through untouched, so you can keep
emphasis, italics, underlines, or
struck-through text exactly as the author left it.
The two panes below the editor render the same document twice: once from the HTML string and once from the JSON document, so you can see both modes at once.
Installation
@editorcn/static-renderer can be installed via the shadcn registry
(recommended) or as a regular npm dependency, same as the editors.
- shadcn registry (recommended) copies the component source into your
project (under
components/static-renderer), so you own and can customize it. - npm installs the package as a regular dependency. Use this if you'd rather not vendor the component source into your repo.
Option 1: shadcn registry (recommended)
Add the editorcn registry to your components.json if you haven't already
(see Getting Started):
{
"$schema": "https://ui.shadcn.com/schema/registry.json",
"registries": {
"@editorcn": "https://editorcn.vercel.app/r/{name}.json"
}
}Then install:
$ pnpm dlx shadcn@latest add @editorcn/static-renderer
The CLI copies StaticRenderer and its stylesheet into
components/static-renderer and installs the required dependencies. Import
from the copied source:
import { StaticRenderer } from "@/components/static-renderer";
import "@/components/static-renderer/style.css";Option 2: npm
$ pnpm add @editorcn/static-renderer
Import the component and its stylesheet from the package:
import { StaticRenderer } from "@editorcn/static-renderer";
import "@editorcn/static-renderer/style.css";Order matters: import the stylesheet after your shadcn globals so the CSS variables are defined:
@import "./globals.css"; /* your shadcn theme variables */
@import "@editorcn/static-renderer/style.css";Usage
Import paths below use the npm install. When installed via the shadcn registry, import from
@/components/static-rendererinstead.
Pick a mode based on what you stored:
- HTML: you saved
editor.getHTML(). Simplest option; pass the string ascontentand you're done. - JSON: you saved
editor.getJSON(), or render a document received from an API. Pass the document ascontentplus theextensionsthat built it.
HTML string
import { StaticRenderer } from "@editorcn/static-renderer";
function PostView({ html }: { html: string }) {
return <StaticRenderer content={html} />;
}JSON document
import { StaticRenderer } from "@editorcn/static-renderer";
import StarterKit from "@tiptap/starter-kit";
const extensions = [StarterKit];
function PostView({ json }: { json: Record<string, unknown> }) {
return <StaticRenderer content={json} extensions={extensions} />;
}Every node and mark type referenced by the document must be registered in
extensions, otherwise rendering throws (mirroring Node.fromJSON). The
extensions array should match the one your editor was created with.
Custom nodes
Custom nodes come from the Tiptap extensions you registered in your editor.
Each registered node appears as { "type": "<name>" } inside
editor.getJSON(). To replace its default static output with your own
component, map the node name under options.nodeMapping.
The rest of this section walks through the full flow: a custom media node
used in the editor, the JSON it produces, and how the renderer displays it.
1. Define the node extension (media.ts):
import { Node } from "@tiptap/core";
export const Media = Node.create({
name: "media",
group: "block",
atom: true,
addAttributes() {
return {
src: { default: null },
alt: { default: null },
};
},
parseHTML() {
return [{ tag: "img[src]" }];
},
renderHTML({ HTMLAttributes }) {
return ["img", HTMLAttributes];
},
});2. Use it in the editor (post-editor.tsx):
"use client";
import { RichTextEditor } from "@editorcn/editor";
import { useEditor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import { Media } from "./media";
const extensions = [StarterKit, Media];
function PostEditor({ onSave }: { onSave: (json: unknown) => void }) {
const editor = useEditor({
extensions,
content: "<p>Write your post...</p>",
onCreate({ editor: ed }) {
onSave(ed.getJSON());
},
onUpdate({ editor: ed }) {
onSave(ed.getJSON());
},
});
return (
<RichTextEditor editor={editor}>
<RichTextEditor.Content />
</RichTextEditor>
);
}Every time the author types, onSave receives the current document as JSON,
including the media node:
{
"type": "doc",
"content": [
{
"type": "media",
"attrs": { "src": "/diagram.png", "alt": "Architecture" }
}
]
}Persist that JSON (a database column, an API response, a file) and hand it to the reader-facing side of your app.
3. Render it (post-view.tsx):
import { StaticRenderer } from "@editorcn/static-renderer";
import StarterKit from "@tiptap/starter-kit";
import type { Node } from "@tiptap/pm/model";
import { Media } from "./media";
const extensions = [StarterKit, Media];
function MediaStatic({ node }: { node: Node }) {
return (
<figure>
<img src={node.attrs.src} alt={node.attrs.alt} className="rounded-lg" />
<figcaption>Rendered by a custom nodeMapping</figcaption>
</figure>
);
}
function PostView({ json }: { json: Record<string, unknown> }) {
return (
<StaticRenderer
content={json}
extensions={extensions}
options={{
nodeMapping: { media: ({ node }) => <MediaStatic node={node} /> },
}}
/>
);
}PostView can live anywhere: the same page as the editor, a separate route
that fetches the post by id, or a server component receiving JSON from your API.
The preview above uses the same pattern with the built-in image node from
@tiptap/extension-image: the JSON pane maps image to a custom component
that wraps the image in a figure with a caption.
nodeMappingvalues render as standard React elements, so they work in both client components (interactive) and in RSC (static). Node views (ReactNodeViewRenderer) don't work outside a live editor; usenodeMappinginstead.
Props
StaticRenderer renders a wrapper with the rte-static-renderer class. It
accepts the usual HTMLAttributes plus:
| Prop | Type | Default | Description |
|---|---|---|---|
content | string | JSONContent | Node | - | HTML from editor.getHTML(), or a ProseMirror JSON document |
as | ElementType | "div" | Root element to render |
className | string | - | Merged with the wrapper class via tailwind-merge |
extensions | Extensions | - | Tiptap extensions used to render JSON content (JSON mode only) |
staticEditorOptions | StaticEditorOptions | - | Editor-level options applied while rendering JSON |
options | Partial<TiptapStaticRendererOptions<ReactNode, Mark, Node>> | - | nodeMapping, markMapping, unhandledNode, ... for custom nodes |
Styling
The styles follow your shadcn theme (--foreground, --primary, --border,
--muted, --radius, --font-mono, ...) and adapt to dark mode
automatically.
Supported output
The stylesheet covers every block element the editors serialize:
| Content | Selector |
|---|---|
| Headings | .rte-static-renderer h1 – h6 |
| Paragraphs | .rte-static-renderer p |
| Lists | .rte-static-renderer ul, ol, li |
| Blockquote | .rte-static-renderer blockquote |
| Inline code | .rte-static-renderer code |
| Code blocks | .rte-static-renderer pre, pre code |
| Syntax highlighting | .rte-static-renderer pre .hljs-* (lowlight / highlight.js) |
| Horizontal rule | .rte-static-renderer hr |
| Links | .rte-static-renderer a |
| Images | .rte-static-renderer img |
| Tables | .rte-static-renderer table, th, td, .tableWrapper |
| Task lists | .rte-static-renderer ul[data-type="taskList"] |
| Embeds (toolbar) | .rte-static-renderer div[data-type="youtube" / "twitter"] |
YouTube and Twitter embeds are serialized as empty <div>s carrying
data-type, data-src / data-tweet-id attributes, which render as 16:9
placeholder cards. To show the live media in JSON mode, provide a
nodeMapping entry; in HTML mode, replace the markup before rendering.
Scoping overrides
Wrap the renderer in a container and scope your overrides:
.my-post {
--radius: 0.25rem;
--primary: oklch(0.7 0.25 150);
}
.my-post .rte-static-renderer p {
line-height: 1.8;
}Without the component
Prefer plain HTML? Add the rte-static-renderer class yourself:
function RenderContent({ html }: { html: string }) {
return (
<div
className="rte-static-renderer"
dangerouslySetInnerHTML={{ __html: html }}
/>
);
}