/
355
Sponsor

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.

Static output — HTML

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.

editorcn

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
  1. Write in the editor
  2. Save editor.getHTML()
  3. 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.

Static output — JSON

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.

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-renderer instead.

Pick a mode based on what you stored:

  • HTML: you saved editor.getHTML(). Simplest option; pass the string as content and you're done.
  • JSON: you saved editor.getJSON(), or render a document received from an API. Pass the document as content plus the extensions that 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.

nodeMapping values 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; use nodeMapping instead.

Props

StaticRenderer renders a wrapper with the rte-static-renderer class. It accepts the usual HTMLAttributes plus:

PropTypeDefaultDescription
contentstring | JSONContent | Node-HTML from editor.getHTML(), or a ProseMirror JSON document
asElementType"div"Root element to render
classNamestring-Merged with the wrapper class via tailwind-merge
extensionsExtensions-Tiptap extensions used to render JSON content (JSON mode only)
staticEditorOptionsStaticEditorOptions-Editor-level options applied while rendering JSON
optionsPartial<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:

ContentSelector
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 }}
    />
  );
}