Skip to main content
The schema is the heart of your editor’s document structure. It defines what nodes and marks are available, how they can be nested, and what attributes they can have.

What is a Schema?

In ProseMirror (and Tiptap), a schema is a specification that defines:

Node Types

What block and inline elements can exist in your document (paragraphs, headings, images, etc.)

Mark Types

What formatting can be applied to text (bold, italic, links, etc.)

Content Rules

What content each node can contain (e.g., a paragraph can contain inline content)

Attributes

What attributes nodes and marks can have (e.g., heading level, link href)

How Tiptap Creates a Schema

Unlike ProseMirror where you define the schema manually, Tiptap automatically generates the schema from your extensions.
import { Editor } from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'

const editor = new Editor({
  extensions: [
    StarterKit,
  ],
})

// Schema is automatically generated
console.log(editor.schema)
Source: /home/daytona/workspace/source/packages/core/src/Editor.ts:469

Schema Generation Process

  1. Extensions are collected - All extensions (nodes, marks, and generic extensions) are gathered
  2. Extensions are resolved - Dependencies and priorities are resolved
  3. Schema is built - Node specs and mark specs are extracted and combined
  4. ProseMirror schema is created - A ProseMirror Schema object is instantiated
Source: /home/daytona/workspace/source/packages/core/src/helpers/getSchema.ts:8

Accessing the Schema

You can access the schema through the editor:
// Get the schema
const schema = editor.schema

// Access node types
const paragraphType = schema.nodes.paragraph
const headingType = schema.nodes.heading
const imageType = schema.nodes.image

// Access mark types
const boldType = schema.marks.bold
const linkType = schema.marks.link
const italicType = schema.marks.italic
Source: /home/daytona/workspace/source/packages/core/src/Editor.ts:65

Schema Structure

Node Specs

Each node in the schema has a specification that defines its behavior:
{
  // The node type
  paragraph: {
    // Content expression
    content: 'inline*',
    
    // Group membership
    group: 'block',
    
    // Parsing rules
    parseDOM: [
      { tag: 'p' }
    ],
    
    // Rendering rules
    toDOM: () => ['p', 0],
    
    // Attributes
    attrs: {
      textAlign: {
        default: 'left'
      }
    }
  }
}

Mark Specs

Each mark in the schema has a specification:
{
  // The mark type
  bold: {
    // Parsing rules
    parseDOM: [
      { tag: 'strong' },
      { tag: 'b' },
      { style: 'font-weight', getAttrs: value => /^(bold(er)?|[5-9]\d{2,})$/.test(value) && null }
    ],
    
    // Rendering rules
    toDOM: () => ['strong', 0],
    
    // Whether the mark is inclusive (continues when typing at edge)
    inclusive: true,
    
    // Which marks this mark excludes
    excludes: undefined
  }
}

Content Expressions

Content expressions define what content a node can contain. They use a specific syntax:

Syntax

ExpressionMeaning
inline*Zero or more inline elements
block+One or more block elements
text*Zero or more text nodes
paragraph+One or more paragraphs
heading paragraph*A heading followed by zero or more paragraphs
(paragraph | heading)+One or more paragraphs or headings

Examples

import { Node } from '@tiptap/core'

// Paragraph: can contain any inline content
export const Paragraph = Node.create({
  name: 'paragraph',
  group: 'block',
  content: 'inline*', // Zero or more inline elements
})

// Heading: can contain inline content
export const Heading = Node.create({
  name: 'heading',
  group: 'block',
  content: 'inline*',
})

// BlockQuote: must contain at least one block
export const Blockquote = Node.create({
  name: 'blockquote',
  group: 'block',
  content: 'block+', // One or more block elements
})

// ListItem: specific content structure
export const ListItem = Node.create({
  name: 'listItem',
  content: 'paragraph block*', // Paragraph followed by zero or more blocks
})

// Image: no content (leaf node)
export const Image = Node.create({
  name: 'image',
  group: 'block',
  atom: true,
  // No content expression = cannot contain anything
})

// Doc: top-level node
export const Doc = Node.create({
  name: 'doc',
  topNode: true,
  content: 'block+', // Must contain at least one block
})
Source: /home/daytona/workspace/source/packages/core/src/Node.ts:42

Groups

Groups let you reference multiple node types in content expressions:
import { Node } from '@tiptap/core'

// Define nodes with groups
export const Paragraph = Node.create({
  name: 'paragraph',
  group: 'block', // Belongs to 'block' group
  content: 'inline*',
})

export const Heading = Node.create({
  name: 'heading',
  group: 'block', // Also belongs to 'block' group
  content: 'inline*',
})

export const Image = Node.create({
  name: 'image',
  group: 'block',
  atom: true,
})

// Use the group in content expressions
export const Doc = Node.create({
  name: 'doc',
  topNode: true,
  content: 'block+', // Accepts any node in the 'block' group
})

export const Blockquote = Node.create({
  name: 'blockquote',
  group: 'block',
  content: 'block+', // Can contain paragraphs, headings, images, etc.
})

Common Groups

  • block - Block-level content (paragraphs, headings, etc.)
  • inline - Inline content (text, hard breaks, inline images, etc.)
  • list - List-related nodes (bullet lists, ordered lists, etc.)
You can also create custom groups:
export const CalloutBlock = Node.create({
  name: 'calloutBlock',
  group: 'block callout', // Multiple groups
  content: 'block+',
})
Source: /home/daytona/workspace/source/packages/core/src/Node.ts:83

Document Structure

Every Tiptap document has a root doc node that contains all other content:
{
  "type": "doc",
  "content": [
    {
      "type": "heading",
      "attrs": { "level": 1 },
      "content": [
        { "type": "text", "text": "Hello World" }
      ]
    },
    {
      "type": "paragraph",
      "content": [
        { "type": "text", "text": "This is a " },
        {
          "type": "text",
          "marks": [{ "type": "bold" }],
          "text": "bold"
        },
        { "type": "text", "text": " word." }
      ]
    },
    {
      "type": "image",
      "attrs": {
        "src": "https://example.com/image.jpg",
        "alt": "Example"
      }
    }
  ]
}

Schema Validation

The schema enforces content rules. Invalid content is rejected:
// Valid: heading can contain inline content
editor.commands.insertContent({
  type: 'heading',
  attrs: { level: 1 },
  content: [{ type: 'text', text: 'Title' }]
})

// Invalid: heading cannot contain a paragraph (block content)
editor.commands.insertContent({
  type: 'heading',
  attrs: { level: 1 },
  content: [
    { type: 'paragraph', content: [{ type: 'text', text: 'Invalid' }] }
  ]
}) // This will be rejected!

Custom Schema Example

Here’s how to create a custom document structure:
import { Editor } from '@tiptap/core'
import { Document } from '@tiptap/extension-document'
import { Text } from '@tiptap/extension-text'
import { Paragraph } from '@tiptap/extension-paragraph'
import { Heading } from '@tiptap/extension-heading'
import { Bold } from '@tiptap/extension-bold'
import { Italic } from '@tiptap/extension-italic'

// Create a custom doc node that requires a title
const CustomDoc = Document.extend({
  content: 'heading paragraph+',
})

const editor = new Editor({
  extensions: [
    CustomDoc,
    Text,
    Paragraph,
    Heading.configure({ levels: [1] }), // Only h1
    Bold,
    Italic,
  ],
  content: {
    type: 'doc',
    content: [
      {
        type: 'heading',
        attrs: { level: 1 },
        content: [{ type: 'text', text: 'Title' }]
      },
      {
        type: 'paragraph',
        content: [{ type: 'text', text: 'Content' }]
      }
    ]
  },
})

// This document structure is now enforced:
// - Must start with a heading (level 1)
// - Must have at least one paragraph
// - Headings can only be level 1

Inspecting the Schema

View Node Types

// Get all node type names
const nodeNames = Object.keys(editor.schema.nodes)
console.log(nodeNames)
// ['doc', 'paragraph', 'text', 'heading', 'blockquote', ...]

// Get node type details
const paragraphType = editor.schema.nodes.paragraph
console.log(paragraphType.spec.content) // 'inline*'
console.log(paragraphType.spec.group) // 'block'

View Mark Types

// Get all mark type names
const markNames = Object.keys(editor.schema.marks)
console.log(markNames)
// ['bold', 'italic', 'link', 'code', ...]

// Get mark type details
const boldType = editor.schema.marks.bold
console.log(boldType.spec.inclusive) // true

Check Content Validity

import { Fragment, Slice } from '@tiptap/pm/model'

// Check if content is valid for a node type
const paragraphType = editor.schema.nodes.paragraph
const content = Fragment.from([
  editor.schema.text('Hello '),
  editor.schema.text('world', [editor.schema.marks.bold.create()])
])

const isValid = paragraphType.validContent(content)
console.log(isValid) // true

Using Schema Types in Commands

// Get node type from schema
const headingType = editor.schema.nodes.heading

// Use in commands
editor.commands.setNode(headingType, { level: 1 })

// Get mark type from schema
const boldType = editor.schema.marks.bold

// Use in commands
editor.commands.toggleMark(boldType)

Advanced: Schema Introspection

// Iterate over all node types
for (const [name, nodeType] of Object.entries(editor.schema.nodes)) {
  console.log(`Node: ${name}`)
  console.log(`  Content: ${nodeType.spec.content}`)
  console.log(`  Group: ${nodeType.spec.group}`)
  console.log(`  Inline: ${nodeType.isInline}`)
  console.log(`  Block: ${nodeType.isBlock}`)
  console.log(`  Leaf: ${nodeType.isLeaf}`)
}

// Iterate over all mark types
for (const [name, markType] of Object.entries(editor.schema.marks)) {
  console.log(`Mark: ${name}`)
  console.log(`  Inclusive: ${markType.spec.inclusive}`)
  console.log(`  Excludes: ${markType.spec.excludes}`)
}

Best Practices

Start with StarterKit

Use StarterKit as a foundation, then add or remove extensions as needed.
import StarterKit from '@tiptap/starter-kit'

const editor = new Editor({
  extensions: [
    StarterKit.configure({
      heading: {
        levels: [1, 2, 3],
      },
    }),
  ],
})

Be Explicit with Content

Clearly define what content each node can contain to prevent invalid structures.
// Good: Explicit content rules
content: 'heading paragraph+'

// Less good: Too permissive
content: 'block*'

Use Groups Wisely

Group similar nodes together to simplify content expressions.
// Create a custom group
group: 'block customBlock'

// Reference it in content
content: 'customBlock+'

Validate Content

Enable content validation in development to catch schema violations early.
const editor = new Editor({
  enableContentCheck: true,
  onContentError: ({ error }) => {
    console.error('Invalid content:', error)
  },
})

Schema vs Extensions

AspectProseMirror SchemaTiptap Extensions
DefinitionManual JSON objectDeclarative extension classes
FlexibilityFull control, verboseHigh-level API, concise
MaintenanceManual updatesAutomatic from extensions
ComposabilityDifficultEasy to mix and match
Learning CurveSteepGentle
Tiptap’s extension system is built on top of ProseMirror’s schema. You get the power of ProseMirror with a much simpler API.

Nodes & Marks

Learn how to create nodes and marks

Extensions

Learn about the extension system

Editor

Learn about the Editor class

Commands

Learn how to manipulate content