Schemas from scratch

ProseMirror schemas provide something like a syntax for documents—they set down which structures are valid.

The most simple schema possible allows the document to be composed just of text.

import {Schema} from "prosemirror-model"

const textSchema = new Schema({
  nodes: {
    text: {},
    doc: {content: "text*"}

You can use it to edit inline content. (A ProseMirror view can be mounted on any node, including inline nodes.)


To add more structure, you'll usually want to add some kind of block nodes. For example, this schema consists of notes that can optionally be grouped with group nodes.

const noteSchema = new Schema({
  nodes: {
    text: {},
    note: {
      content: "text*",
      toDOM() { return ["note", 0] },
      parseDOM: [{tag: "note"}]
    notegroup: {
      content: "note+",
      toDOM() { return ["notegroup", 0] },
      parseDOM: [{tag: "notegroup"}]
    doc: {
      content: "(note | notegroup)+"

For nodes that aren't text or top-level nodes, it is necessary to provide toDOM methods, so that the editor can render them, and parseDOM values, so that they can be parsed. This schema uses custom DOM nodes <note> and <notegroup> to represent its nodes.

You can press ctrl-space to add a group around the selected notes. To get that functionality, you first have to implement a custom editing command. Something like this:

import {findWrapping} from "prosemirror-transform"

function makeNoteGroup(state, dispatch) {
  // Get a range around the selected blocks
  let range = state.selection.$from.blockRange(state.selection.$to)
  // See if it is possible to wrap that range in a note group
  let wrapping = findWrapping(range, noteSchema.nodes.notegroup)
  // If not, the command doesn't apply
  if (!wrapping) return false
  // Otherwise, dispatch a transaction, using the `wrap` method to
  // create the step that does the actual wrapping.
  if (dispatch) dispatch(, wrapping).scrollIntoView())
  return true

A keymap like keymap({"Ctrl-Space": makeNoteGroup}) can be used to enable it.

The generic bindings for enter and backspace work just fine in this schema—enter will split the textblock around the cursor, or if that's empty, try to lift it out of its parent node, and thus can be used to create new notes and escape from a note group. Backspace at the start of a textblock will lift that textblock out of its parent, which can be used to remove notes from a group.

Groups and marks

Let's do one more, with stars and shouting.

This schema has not just text as inline content, but also stars, which are just inline nodes. To be able to easily refer to both our inline nodes, they are tagged as a group (also called "inline"). The schema does the same for the two types of block nodes, one paragraph type that allows any inline content, and one that only allows unmarked text.

let starSchema = new Schema({
  nodes: {
    text: {
      group: "inline",
    star: {
      inline: true,
      group: "inline",
      toDOM() { return ["star", "🟊"] },
      parseDOM: [{tag: "star"}]
    paragraph: {
      group: "block",
      content: "inline*",
      toDOM() { return ["p", 0] },
      parseDOM: [{tag: "p"}]
    boring_paragraph: {
      group: "block",
      content: "text*",
      marks: "",
      toDOM() { return ["p", {class: "boring"}, 0] },
      parseDOM: [{tag: "p.boring", priority: 60}]
    doc: {
      content: "block+"

Since textblocks allow marks by default, the boring_paragraph type sets marks to the empty string to explicitly forbid them.

The schema defines two types of marks, shouted text and links. The first is like the common strong or emphasis marks, in that it just adds a single bit of information to the content it marks, and doesn't have any attributes. It specifies that it should be rendered as a <shouting> tag (which is styled to be inline, bold, and uppercase), and that that same tag should be parsed as this mark.

  marks: {
    shouting: {
      toDOM() { return ["shouting", 0] },
      parseDOM: [{tag: "shouting"}]
    link: {
      attrs: {href: {}},
      toDOM(node) { return ["a", {href: node.attrs.href}, 0] },
      parseDOM: [{tag: "a", getAttrs(dom) { return {href: dom.href} }}],
      inclusive: false

Links do have an attribute—their target URL, so their DOM serializing method has to output that (the second element in an array returned from toDOM, if it's a plain object, provides a set of DOM attributes), and their DOM parser has to read it.

By default, marks are inclusive, meaning that they get applied to content inserted at their end (as well as at their start when they start at the start of their parent node). For link-type marks, this is usually not the expected behavior, and the inclusive property on the mark spec can be set to false to disable that behavior.

Such as this sentence.
Do laundry Water the tomatoes Buy flour Get toilet paper

This is a nice paragraph, it can have anything in it.

This paragraph is boring, it can't have anything.

Press ctrl/cmd-space to insert a star, ctrl/cmd-b to toggle shouting, and ctrl/cmd-q to add or remove a link.

To make it possible to interact with these elements we again have to add a custom keymap. There's a command helper for toggling marks, which we can use directly for the shouting mark.

import {toggleMark} from "prosemirror-commands"
import {keymap} from "prosemirror-keymap"

let starKeymap = keymap({
  "Ctrl-b": toggleMark(starSchema.marks.shouting),
  "Ctrl-q": toggleLink,
  "Ctrl-Space": insertStar

Toggling a link is a little more involved. En- or disabling non-inclusive marks when nothing is selected isn't meaningful, since you can't “type into’ them like you can with inclusive marks. And we need to ask the user for a URL—but only if a link is being added. So the command uses rangeHasMark to check whether it will be adding or removing, before prompting for a URL.

(prompt is probably not what you'd want to use in a real system. When using an asynchronous method to query the user for something, make sure to use the current state, not the state when the command was originally called, when applying the command's effect.)

function toggleLink(state, dispatch) {
  let {doc, selection} = state
  if (selection.empty) return false
  let attrs = null
  if (!doc.rangeHasMark(selection.from,, {
    attrs = {href: prompt("Link to where?", "")}
    if (!attrs.href) return false
  return toggleMark(, attrs)(state, dispatch)

The command that inserts a star first checks whether the schema allows one to be inserted at the cursor position (using canReplaceWith), and if so, replaces the selection with a newly created star node.

function insertStar(state, dispatch) {
  let type =
  let {$from} = state.selection
  if (!$from.parent.canReplaceWith($from.index(), $from.index(), type))
    return false
  return true