Use renderStaticHtml to render a configured static Plate tree to HTML. Use
editor.api.html.deserialize(...) to parse HTML into Plate
content. Static React rendering and semantic HTML parsing have different
owners, so this guide treats each direction separately.
The fastest way to enable HTML serialization is with the BaseEditorKit, which includes pre-configured base plugins that support HTML conversion for most common elements and marks.
import { AlignKit } from './align';
import { BaseBasicBlocksKit } from './basic-blocks-static';
import { BaseBasicMarksKit } from './basic-marks-static';
import { BaseCalloutKit } from './callout-static';
import { BaseCodeBlockKit } from './code-block-static';
import { BaseCodeDrawingKit } from './code-drawing-static';
import { BaseColumnKit } from './column-static';
import { BaseDateKit } from './date-static';
import { BaseDetailsKit } from './details-static';
import { BaseFontKit } from './font-static';
import { BaseFootnoteKit } from './footnote-static';
import { LineHeightKit } from './line-height';
import { BaseLinkKit } from './link-static';
import { BaseListKit } from './list-static';
import { MarkdownKit } from './markdown';
import { BaseMathKit } from './math-static';
import { BaseMediaKit } from './media-static';
import { BaseMentionKit } from './mention-static';
import { BaseTableKit } from './table-static';
import { BaseTocKit } from './toc-static';
export const BaseEditorKit = [
...BaseBasicBlocksKit,
...BaseCodeBlockKit,
...BaseCodeDrawingKit,
...BaseTableKit,
...BaseDetailsKit,
...BaseTocKit,
...BaseMediaKit,
...BaseCalloutKit,
...BaseColumnKit,
...BaseMathKit,
...BaseDateKit,
...BaseLinkKit,
...BaseMentionKit,
...BaseFootnoteKit,
...BaseBasicMarksKit,
...BaseFontKit,
...BaseListKit,
...AlignKit,
...LineHeightKit,
...MarkdownKit,
] as const;import { AlignKit } from './align';
import { BaseBasicBlocksKit } from './basic-blocks-static';
import { BaseBasicMarksKit } from './basic-marks-static';
import { BaseCalloutKit } from './callout-static';
import { BaseCodeBlockKit } from './code-block-static';
import { BaseCodeDrawingKit } from './code-drawing-static';
import { BaseColumnKit } from './column-static';
import { BaseDateKit } from './date-static';
import { BaseDetailsKit } from './details-static';
import { BaseFontKit } from './font-static';
import { BaseFootnoteKit } from './footnote-static'
import { createEditor } from 'platejs';
import { renderStaticHtml } from 'platejs/static';
import { BaseEditorKit } from '@/components/editor/plugins-static';
const editor = createEditor({
plugins: BaseEditorKit,
initialValue: [
{ type: 'heading', level: 1, children: [{ text: 'Hello World' }] },
{ type: 'paragraph', children: [{ text: 'This content will be serialized to HTML.' }] },
],
});
// Serialize to HTML
const html = await renderStaticHtml(editor);See a complete server-side HTML generation example:
import fs from 'node:fs/promises';
import path from 'node:path';
import { cva } from 'class-variance-authority';
import type { Metadata } from 'next';
import type { EditorDocumentValue } from 'platejs';
import { createStaticEditor, renderStaticHtml } from 'platejs/static';
import * as React from 'react';
import { EditorStatic } from '@/components/editor/editor-static';
import {
EditorClient,
EditorViewClient,
ExportHtmlButton,
HtmlIframe,
Convert Plate editor content (Plate nodes) into an HTML string. This is often done server-side.
When using renderStaticHtml or other Plate utilities in a server environment (Node.js, RSC), you must not import from /react subpaths of any platejs* package. Always use the base imports (e.g., platejs instead of platejs/react).
This means you should use createEditor from platejs for server-side editor instances, not useCreateEditor or createEditor from platejs/react.
Provide a server-side editor instance with server-safe components bound to their owning plugins.
import { createEditor } from 'platejs';
import { renderStaticHtml } from 'platejs/static'; // Static import
// Import a server-safe registry kit (NOT from /react package paths)
import { BaseBasicBlocksKit } from '@/components/editor/basic-blocks-static';
const editor = createEditor({
plugins: [
...BaseBasicBlocksKit,
],
initialValue: [
{ type: 'heading', level: 1, children: [{ text: 'My Title' }] },
{ type: 'paragraph', children: [{ text: 'My content.' }] },
],
});
async function getMyHtml() {
const html = await renderStaticHtml(editor);
return html;
}import { createEditor } from 'platejs';
import { renderStaticHtml } from 'platejs/static'; // Static import
// Import a server-safe registry kit (NOT from /react package paths)
import { BaseBasicBlocksKit } from '@/components/editor/basic-blocks-static';
const editor = createEditor({
plugins: [
...BaseBasicBlocksKit,
],
initialValue: [
{ type: 'heading', level: 1, children: [{ text: 'My Title' }] },
{ type: 'paragraph', children: [{ text: 'My content.' }] },
],
});
async function getMyHtml() {
const html =
renderStaticHtml returns only the HTML rendered for the editor content itself.
If you use styled components such as EditorStatic or custom static components
with classes, include their CSS wherever the HTML is displayed.
This often means wrapping the serialized HTML in a full HTML document that includes your stylesheets:
// ... (previous setup from generate-html.ts)
async function getFullHtmlDocument() {
const editorHtmlContent = await getMyHtml(); // From previous example
const fullHtml = `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="/path/to/your-global-styles.css" />
<link rel="stylesheet" href="/path/to/tailwind-or-component-styles.css" />
<title>Serialized Content</title>
</head>
<body>
<div class="my-document-wrapper prose dark:prose-invert">
${editorHtmlContent}
</div>
</body>
</html>`;
return
The serialization process converts Plate nodes to static HTML. Interactive features (React event handlers, client-side hooks) or components relying on browser APIs will not function in the serialized output.
For server-side serialization, you must use static versions of your components (no client-only code, no React hooks like useEffect or useState).
Refer to the Static Rendering Guide for detailed instructions on creating server-safe static components for your Plate elements and marks.
import React from 'react';
import type { BaseParagraphPlugin } from 'platejs';
import type { EditorElementProps } from 'platejs/static';
// Example static paragraph component
export function ParagraphElementStatic(
props: EditorElementProps<typeof BaseParagraphPlugin>
) {
return (
<EditorElement {...props} className={cn('m-0 px-0 py-1')}>
{props.children}
</EditorElement>
);
}Use renderAuthoredHtml when the editor contains native authored changes:
import { renderAuthoredHtml } from 'platejs/static';
const output = await renderAuthoredHtml(editor, {
projection: 'review',
});import { renderAuthoredHtml } from 'platejs/static';
const output = await renderAuthoredHtml(editor, {
projection: 'review',
});accepted and proposed render the chosen static content and report authored-lossy-projection when pending changes are omitted. review renders proposed static content and appends an escaped application/vnd.editor.authored+json script containing the canonical document envelope.
Call deserializeAuthoredHtml(editor, output.data) to recover that envelope. HTML without an authored script follows the editor's installed HTML codecs. See Authored Changes for the complete format contract.
The HTML decoder converts strings or DOM elements back into Plate content. It preserves structure, formatting, and attributes when the installed plugins own matching codecs.
Call the root HTML API from a client-side Plate editor.
import { useCreateEditor } from 'platejs/react';
// Import ALL Plate plugins needed to represent the HTML content
import { BasicBlocksKit } from '@/components/editor/basic-blocks';
// ... and so on for bold, italic, tables, lists, etc.
function MyHtmlImporter({ htmlString }: { htmlString: string }) {
const editor = useCreateEditor({
plugins: [
...BasicBlocksKit, // Paragraph, headings, blockquote, and horizontal rule
// ... include all plugins corresponding to the HTML you expect to parse
],
});
const handleImport = () => {
const value = editor.api.html.deserialize({ element: htmlString });
if (!value) return;
editor.update.value.replace({ children: value });
};
// ... render your editor and a button to trigger handleImport ...
return <button onClick={handleImport}>Import HTML</button>;
}import { useCreateEditor } from 'platejs/react';
// Import ALL Plate plugins needed to represent the HTML content
import { BasicBlocksKit } from '@/components/editor/basic-blocks';
// ... and so on for bold, italic, tables, lists, etc.
function MyHtmlImporter({ htmlString }: { htmlString: string }) {
const editor = useCreateEditor({
plugins: [
...BasicBlocksKit, // Paragraph, headings, blockquote, and horizontal rule
// ... include all plugins corresponding to the HTML you expect to parse
],
});
const handleImport = () => {
const value = editor.api.html.deserialize({ element: htmlString });
HTML deserialization through editor.api.html.deserialize is
typically a client-side operation because it uses the compiled Plate plugin
model.
Each Plate plugin owns the HTML tags, styles, and attributes for its schema
claim. The same 'text/html' map returned by context-bound defineCodecs
handles decode and encode.
| HTML Element / Style | Plate Plugin (Typical) | Notes |
|---|---|---|
<strong>, <b>, font-weight: 600,700,bold | BoldPlugin | Converts to bold: true mark. |
<em>, <i>, font-style: italic | ItalicPlugin | Converts to mark. |
Persisted element types come from the installed plugin schema handle (for
example, editor.plugin(ParagraphPlugin).schema.type). The table shows typical associations.
Include the corresponding Plate plugins for these rules to apply.
defineCodecsPackage authors declare node-level HTML meaning in the constructor's codecs
callback before the app's terminal .configure() call. Destructure the
context-bound defineCodecs and pass it the MIME-keyed map. This is the one
inline inference anchor for the plugin schema and codec callbacks:
match is a non-empty array of tag, class, attribute, or style matchers.decode returns only the value or properties owned by the plugin. Element
codecs do not return type; Plate supplies the installed configured type.encode returns a wrapper for a mark, a full node spec for an element, or an
attribute/style patch for an element property.decodeOnly: true instead of omitting encode
silently.priority resolves intentional overlap. Equal-priority exclusive claims
fail model compilation instead of depending on plugin array order.This element codec maps <aside> to a callout and preserves an app-configured
storage type:
import { property, schema } from 'platejs';
import { definePlugin } from 'platejs/react';
const CalloutPlugin = definePlugin('callout', {
codecs: ({ defineCodecs }) =>
defineCodecs({
'text/html': {
decode: ({ element }) => ({
variant: element.dataset.variant || undefined,
}),
encode: ({ content, node }) => ({
attributes: { 'data-variant': node.variant },
children: content,
tag:
CalloutPlugin decodes <aside> as a callout element because the codec
targets the installed descriptor. Define a separate named descriptor when a
different persisted identity is required; .configure() does not rename one.
Use defineCodecs(map) for self and product codecs. A plugin contributing HTML
behavior to another descriptor uses defineCodecs(TargetPlugin, map); the
helper injects that target into every rule. Do not put target in the rule
manually. One plugin may keep multiple HTML representations in a non-empty
ordered 'text/html' tuple inside the same map.
Use query, transformData, and transformFragment on the plugin's
'text/html' codec for work that needs the complete incoming payload. These
hooks run before or after node decoding; they do not declare node matches.
const HtmlCleanupPlugin = definePlugin('htmlCleanup', {
codecs: ({ defineCodecs }) =>
defineCodecs({
'text/html': {
query: ({ source }) => source.types.includes('text/html'),
transformData: ({ data }) =>
data.replaceAll(/<!--(?:Start|End)Fragment-->/g, ''),
},
}),
});Converts Plate nodes from editor.read.children() (or a provided value) into an HTML string. This function is typically used server-side.
A React component to wrap the entire editor content during static rendering. Defaults to EditorStatic.
The component receives editor and any props passed here.
Props to pass to the editorComponent. P defaults to EditorStaticProps.
Class name prefixes to preserve when stripClassNames is true. Default preserve list in the stripping helper: ['editor-'].
If true, removes all class names from the output HTML except those whose prefixes are listed in preserveClassNames. Default: false.
If true, removes all data-* attributes from the output HTML. Default: false.
Parses an HTML string or HTMLElement into a Plate Value (an array of Descendant nodes). This is typically used on the client-side with a fully configured Plate editor.
import { createEditor } from 'platejs';
import { renderStaticHtml } from 'platejs/static';
import { BaseEditorKit } from '@/components/editor/plugins-static';
const editor = createEditor({
plugins: BaseEditorKit,
initialValue: [
{ type: 'heading', level: 1, children: [{ text: 'Hello World' }] },
{ type: 'paragraph', children: [{ text: 'This content will be serialized to HTML.' }] },
],
});
// Serialize to HTML
const html = await renderStaticHtml(editor);import fs from 'node:fs/promises';
import path from 'node:path';
import { cva } from 'class-variance-authority';
import type { Metadata } from 'next';
import type { EditorDocumentValue } from 'platejs';
import { createStaticEditor, renderStaticHtml } from 'platejs/static';
import * as React from 'react';
import { EditorStatic } from '@/components/editor/editor-static';
import {
EditorClient,
EditorViewClient,
ExportHtmlButton,
HtmlIframe,
} from '@/components/editor/html-export';
import { HtmlExportKit } from '@/components/editor/html-export-kit';
import { alignValue } from '@/registry/examples/values/align-value';
import { basicBlocksValue } from '@/registry/examples/values/basic-blocks-value';
import { basicMarksValue } from '@/registry/examples/values/basic-marks-value';
import { columnValue } from '@/registry/examples/values/column-value';
import { dateValue } from '@/registry/examples/values/date-value';
import { equationValue } from '@/registry/examples/values/equation-value';
import { fontValue } from '@/registry/examples/values/font-value';
import { indentValue } from '@/registry/examples/values/indent-value';
import { lineHeightValue } from '@/registry/examples/values/line-height-value';
import { linkValue } from '@/registry/examples/values/link-value';
import { listValue } from '@/registry/examples/values/list-value';
import { mediaValue } from '@/registry/examples/values/media-value';
import { mentionValue } from '@/registry/examples/values/mention-value';
import { suggestionValue } from '@/registry/examples/values/suggestion-value';
import { tableValue } from '@/registry/examples/values/table-value';
import { tocPlaygroundValue } from '@/registry/examples/values/toc-value';
export const metadata: Metadata = {
title: 'HTML Export',
};
const getCachedTailwindCss = React.cache(async () => {
const cssPath = path.join(process.cwd(), 'public', 'tailwind.css');
return await fs.readFile(cssPath, 'utf-8');
});
const createHtmlDocument = ({
editorHtml,
katexCDN,
tailwindCss,
theme,
}: {
editorHtml: string;
tailwindCss: string;
katexCDN?: string;
theme?: string;
}) => `<!DOCTYPE html>
<html lang="en"${theme === 'dark' ? ' class="dark"' : ''}>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light dark" />
<style>${tailwindCss}</style>
${katexCDN}
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400..700&family=JetBrains+Mono:wght@400..700&display=swap"
rel="stylesheet"
/>
<style>
:root {
--font-sans: 'Inter', 'Inter Fallback';
--font-mono: 'JetBrains Mono', 'JetBrains Mono Fallback';
}
</style>
</head>
<body>
${editorHtml}
</body>
</html>`;
const createValue = (): EditorDocumentValue => ({
children: [
...basicBlocksValue,
...basicMarksValue,
...tocPlaygroundValue,
...linkValue,
...tableValue,
...equationValue,
...columnValue,
...mentionValue,
...dateValue,
...fontValue,
...suggestionValue,
...alignValue,
...lineHeightValue,
...indentValue,
...listValue,
...mediaValue.children,
],
});
export default async function HtmlExportBlock() {
const editor = createStaticEditor({
plugins: HtmlExportKit,
initialValue: createValue(),
});
const tailwindCss = await getCachedTailwindCss();
const katexCDN = `<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.18/dist/katex.css" integrity="sha384-9PvLvaiSKCPkFKB1ZsEoTjgnJn+O3KvEwtsz37/XrkYft3DTk2gHdYvd9oWgW3tV" crossorigin="anonymous">`;
// const cookieStore = await cookies();
// const theme = cookieStore.get('theme')?.value;
const theme = 'light';
// Get the editor content HTML using EditorStatic
const editorHtml = await renderStaticHtml(editor, {
editorComponent: EditorStatic,
props: { style: { padding: '0 calc(50% - 350px)', paddingBottom: '' } },
});
// Create the full HTML document
const html = createHtmlDocument({
editorHtml,
katexCDN,
tailwindCss,
theme,
});
return (
<div className="grid grid-cols-3 px-4">
<div className="p-2">
<h3 className={headingVariants()}>Editor</h3>
<EditorClient value={createValue()} />
</div>
<div className="p-2">
<h3 className={headingVariants()}>EditorView</h3>
<EditorViewClient value={createValue()} />
</div>
<div className="relative p-2">
<h3 className={headingVariants()}>HTML Iframe</h3>
<ExportHtmlButton
className="absolute top-10 right-0"
html={html}
serverTheme={theme}
/>
<HtmlIframe
className="h-[7500px] w-full"
html={html}
serverTheme={theme}
/>
</div>
</div>
);
}
const headingVariants = cva(
'group mt-8 scroll-m-20 font-heading font-semibold text-xl tracking-tight'
);// ... (previous setup from generate-html.ts)
async function getFullHtmlDocument() {
const editorHtmlContent = await getMyHtml(); // From previous example
const fullHtml = `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="/path/to/your-global-styles.css" />
<link rel="stylesheet" href="/path/to/tailwind-or-component-styles.css" />
<title>Serialized Content</title>
</head>
<body>
<div class="my-document-wrapper prose dark:prose-invert">
${editorHtmlContent}
</div>
</body>
</html>`;
return fullHtml;
}import React from 'react';
import type { BaseParagraphPlugin } from 'platejs';
import type { EditorElementProps } from 'platejs/static';
// Example static paragraph component
export function ParagraphElementStatic(
props: EditorElementProps<typeof BaseParagraphPlugin>
) {
return (
<EditorElement {...props} className={cn('m-0 px-0 py-1')}>
{props.children}
</EditorElement>
);
}italic: true<u>, text-decoration: underline | UnderlinePlugin | Converts to underline: true mark. |
<s>, <del>, <strike>, text-decoration: line-through | StrikethroughPlugin | Converts to strikethrough: true mark. |
<sub>, vertical-align: sub | ScriptPlugin | Converts to script: 'sub'. |
<sup>, vertical-align: super | ScriptPlugin | Converts to script: 'sup'. |
<code> (not in <pre>), font-family: Consolas | CodePlugin | Converts to code: true mark (inline code). |
<kbd> | KbdPlugin | Converts to kbd: true mark. |
<p> | ParagraphPlugin | Converts to paragraph element. |
<h1> - <h6> | HeadingPlugin–HeadingPlugin | Converts to corresponding heading elements (h1 - h6). |
<ul>, <ol>, <li> | ListPlugin | Converts list items to blocks with indent and listStyle properties. |
<blockquote> | BlockquotePlugin | Converts to blockquote element. |
<pre> (often with <code> inside) | CodeBlockPlugin | Converts to codeBlock with one newline-bearing text child. |
<hr> | HorizontalRulePlugin | Converts to horizontal rule element. |
<a> | LinkPlugin | Converts to link with a url property. |
<img> | ImagePlugin | Converts to image with a url property. |
<iframe> | MediaEmbedPlugin | Converts to media embed element, attempting to parse URL. |
<table> | TablePlugin | Converts to table element. |
<tr> | TablePlugin | Converts to tableRow. |
<td> | TablePlugin | Converts to tableCell. |
<th> | TablePlugin | Converts to tableCell with header: true. |
style="background-color: ..." | FontBackgroundColorPlugin | Converts to backgroundColor mark. |
style="color: ..." | FontColorPlugin | Converts to color mark. |
style="font-family: ..." | FontFamilyPlugin | Converts to fontFamily mark. |
style="font-size: ..." | FontSizePlugin | Converts to fontSize mark. |
style="font-weight: ..." (other than bold values) | FontWeightPlugin | Converts to fontWeight mark for non-standard bold values. |
<mark> | HighlightPlugin | Converts to highlight: true mark. |
style="text-align: ..." | TextAlignPlugin | Sets textAlign property on block elements. |
style="line-height: ..." | LineHeightPlugin | Sets lineHeight property on block elements. |
import { property, schema } from 'platejs';
import { definePlugin } from 'platejs/react';
const CalloutPlugin = definePlugin('callout', {
codecs: ({ defineCodecs }) =>
defineCodecs({
'text/html': {
decode: ({ element }) => ({
variant: element.dataset.variant || undefined,
}),
encode: ({ content, node }) => ({
attributes: { 'data-variant': node.variant },
children: content,
tag: 'aside',
}),
match: [{ tag: 'aside' }],
},
}),
schema: {
element: {
content: schema.content.text({ default: 'text', min: 1 }),
properties: {
variant: property.string(),
},
},
},
});const HtmlCleanupPlugin = definePlugin('htmlCleanup', {
codecs: ({ defineCodecs }) =>
defineCodecs({
'text/html': {
query: ({ source }) => source.types.includes('text/html'),
transformData: ({ data }) =>
data.replaceAll(/<!--(?:Start|End)Fragment-->/g, ''),
},
}),
});