Slate documents are JSON. Serialization is app code that turns your Slate node shape into another format, and deserialization turns external input back into your node shape.
Read the full persisted document through state.value.get().
const documentValue = editor.read((state) => state.value.get())
const children = documentValue.childrenconst documentValue = editor.read((state) => state.value.get())
const children = documentValue.childrenFor single-root editors, <Slate onChange> gives the current provider root
value. Use state.value.get() when you need extra roots or persistent state
fields. See Document State.
Plain Text
Use NodeApi.string when plain text is enough.
import { NodeApi, type Descendant } from '@platejs/slate'
const serializePlainText = (nodes: readonly Descendant[]) =>
nodes.map((node) => NodeApi.string(node)).join('\n')import { NodeApi, type Descendant } from '@platejs/slate'
const serializePlainText = (nodes: readonly Descendant[]) =>
nodes.map((node) => NodeApi.string(node)).join('\n')Plain text intentionally drops block type, marks, links, comments, and other schema-specific data.
HTML
HTML serialization should match your schema. Slate does not guess how your custom elements map to HTML.
import escapeHtml from 'escape-html'
import { ElementApi, TextApi, type Descendant } from '@platejs/slate'
const serializeHtml = (node: Descendant): string => {
if (TextApi.isText(node)) {
let text = escapeHtml(node.text)
if (node.bold) {
text = `<strong>${text}</strong>`
}
if (node.italic) {
text = `<em>${text}</em>`
}
return text
}
if (!ElementApi.isElement(node)) {
return ''
}
const children = node.children.map(serializeHtml).join('')
switch (node.type) {
case 'quote':
return `<blockquote>${children}</blockquote>`
case 'link':
return `<a href="${escapeHtml(node.url)}">${children}</a>`
case 'paragraph':
return `<p>${children}</p>`
default:
return children
}
}
const html = editor
.read((state) => state.value.root())
.map(serializeHtml)
.join('')import escapeHtml from 'escape-html'
import { ElementApi, TextApi, type Descendant } from '@platejs/slate'
const serializeHtml = (node: Descendant): string => {
if (TextApi.isText(node)) {
let text = escapeHtml(node.text)
if (node.bold) {
text = `<strong>${text}</strong>`
}
if (node.italic) {
text = `<em>${text}</em>`
}
return text
}
if (!ElementApi.isElement(node)) {
return ''
}
const children = node.children.map(serializeHtml).join('')
switch (node.type) {
case 'quote':
return `<blockquote>${children}</blockquote>`
case 'link':
return `<a href="${escapeHtml(node.url)}">${children}</a>`
case 'paragraph':
return `<p>${children}</p>`
default:
return children
}
}
const html = editor
.read((state) => state.value.root())
.map(serializeHtml)
.join('')Escape text and attribute values before writing HTML strings. Keep sanitizer, allowed tag, URL, and table policy in your app schema or paste adapter.
Deserializing HTML
Deserialization is the reverse schema mapping. Parse external HTML, decide which tags your editor accepts, and return Slate nodes.
import type { Descendant, Text } from '@platejs/slate'
type CustomText = Text & {
bold?: boolean
italic?: boolean
}
type DeserializeMarks = Pick<CustomText, 'bold' | 'italic'>
const deserializeElement = (
node: Node,
marks: DeserializeMarks = {}
): Descendant[] => {
if (node.nodeType === Node.TEXT_NODE) {
return [{ ...marks, text: node.textContent ?? '' }]
}
if (node.nodeType !== Node.ELEMENT_NODE) {
return []
}
const element = node as HTMLElement
const nextMarks = {
...marks,
bold: marks.bold || element.nodeName === 'STRONG',
italic: marks.italic || element.nodeName === 'EM',
}
const children = Array.from(element.childNodes).flatMap((child) =>
deserializeElement(child, nextMarks)
)
const safeChildren = children.length > 0 ? children : [{ text: '' }]
switch (element.nodeName) {
case 'BODY':
return children
case 'BLOCKQUOTE':
return [{ type: 'quote', children: safeChildren }]
case 'A':
return [
{
children: safeChildren,
type: 'link',
url: element.getAttribute('href') ?? '',
},
]
case 'P':
return [{ type: 'paragraph', children: safeChildren }]
default:
return children
}
}
const deserializeHtml = (html: string): Descendant[] => {
const document = new DOMParser().parseFromString(html, 'text/html')
return deserializeElement(document.body)
}import type { Descendant, Text } from '@platejs/slate'
type CustomText = Text & {
bold?: boolean
italic?: boolean
}
type DeserializeMarks = Pick<CustomText, 'bold' | 'italic'>
const deserializeElement = (
node: Node,
marks: DeserializeMarks = {}
): Descendant[] => {
if (node.nodeType === Node.TEXT_NODE) {
return [{ ...marks, text: node.textContent ?? '' }]
}
if (node.nodeType !== Node.ELEMENT_NODE) {
return []
}
const element = node as HTMLElement
const nextMarks = {
...marks,
bold: marks.bold || element.nodeName === 'STRONG',
italic: marks.italic || element.nodeName === 'EM',
}
const children = Array.from(element.childNodes).flatMap((child) =>
deserializeElement(child, nextMarks)
)
const safeChildren = children.length > 0 ? children : [{ text: '' }]
switch (element.nodeName) {
case 'BODY':
return children
case 'BLOCKQUOTE':
return [{ type: 'quote', children: safeChildren }]
case 'A':
return [
{
children: safeChildren,
type: 'link',
url: element.getAttribute('href') ?? '',
},
]
case 'P':
return [{ type: 'paragraph', children: safeChildren }]
default:
return children
}
}
const deserializeHtml = (html: string): Descendant[] => {
const document = new DOMParser().parseFromString(html, 'text/html')
return deserializeElement(document.body)
}@platejs/slate-hyperscript is useful for tests and fixtures. Production
deserialization should use explicit parser code so unsupported tags,
attributes, marks, and unsafe URLs are handled deliberately.
Clipboard And Persistence
Clipboard serializers usually need a narrower policy than file export. A paste adapter might preserve links and marks while rejecting layout-only HTML or tables. A persistence serializer should preserve the full document value:
const persisted = editor.read((state) => state.value.get())
await saveDocument(JSON.stringify(persisted))const persisted = editor.read((state) => state.value.get())
await saveDocument(JSON.stringify(persisted))Keep comment bodies, permissions, audit data, and large external records in app or collaboration stores. Store only the ids or state fields that belong to the document model.