Serializing

PreviousNext

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.children
const documentValue = editor.read((state) => state.value.get())
const children = documentValue.children

For 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.