Content Documents

Learn how to work with our rich JSON documents and the relational nodes

When working with content collections, you will often encounter body and editor_nodes fields. These fields contain Tiptap editor instances enriched with relational data for custom nodes. This article describes how to process and render this data.

Although JSON documents typically use the body field, some collections store their Tiptap JSON in a different field, such as description.

Working with Tiptap documents

Our Data Studio uses Tiptap v2 to create rich text documents. In addition to common nodes and marks, it supports several custom extensions (referred to as custom nodes). Tiptap stores its content as a tree-like JSON structure.

{
    "body": {
        "type": "doc",
        "content": [
            {
                "type": "paragraph",
                "content": [
                    {
                        "type": "text",
                        "text": "Hello world!"
                    }
                ]
            }
        ]
    }
}

When fetching these documents from the Onderwijsloket API, the following node and mark types may appear:

Nodes

Marks

When writing a renderer or processor, ensure your implementation handles these node types. Tiptap provides utilities for working with documents, including PHP helpers. Example usage:

// Rendering a simple document as HTML
import { type JSONContent, generateText } from '@tiptap/core'
import { generateHTML } from '@tiptap/html'

// Import extensions
import Blockquote from '@tiptap/extension-blockquote';
import Bold from '@tiptap/extension-bold';
import BulletList from '@tiptap/extension-bullet-list';
import Code from '@tiptap/extension-code';
import CodeBlock from '@tiptap/extension-code-block';
import Document from '@tiptap/extension-document';
import HardBreak from '@tiptap/extension-hard-break';
import Heading from '@tiptap/extension-heading';
import HorizontalRule from '@tiptap/extension-horizontal-rule';
import Italic from '@tiptap/extension-italic';
import Link from '@tiptap/extension-link';
import ListItem from '@tiptap/extension-list-item';
import OrderedList from '@tiptap/extension-ordered-list';
import Paragraph from '@tiptap/extension-paragraph';
import Strike from '@tiptap/extension-strike';
import Subscript from '@tiptap/extension-subscript';
import Superscript from '@tiptap/extension-superscript';
import { Table } from '@tiptap/extension-table';
import TableCell from '@tiptap/extension-table-cell';
import TableHeader from '@tiptap/extension-table-header';
import TableRow from '@tiptap/extension-table-row';
import Text from '@tiptap/extension-text';

/**
 * Standard TipTap extensions for basic text formatting and structure.
 */
export const extensions = [
  Document,
  Text,
  Paragraph,
  HardBreak,
  Heading,
  CodeBlock,
  BulletList,
  OrderedList,
  ListItem,
  Blockquote,
  HorizontalRule,
  Link,
  Bold,
  Italic,
  Strike,
  Code,
  Subscript,
  Superscript,
  Table,
  TableHeader,
  TableRow,
  TableCell,
];

async function fetchBodyDocument(): JSONContent {
  // fetch data from API
}

const document = await fetchBodyDocument()
const html = generateHTML(document, extensions)
const plainText = generateText(document, extensions)

Most of these extensions are included in @tiptap/starter-kit, but installing them individually reduces bundle size.

When installing @tiptap dependencies, always use a v2 release. Compatibility with v3 is not guaranteed.

Custom nodes

Our content documents include several custom nodes that allow us to embed structured information. Custom nodes fall into three categories:

  • Relation block

  • Relation inline block

  • Relation mark

Currently, only block-level custom nodes and inline blocks are in use; no custom marks are implemented beyond Tiptap’s defaults.

Each custom node is called a relation because its data is stored in its own collection, making it a related resource to the document containing the Tiptap content. These relationships are not stored in the JSON document, but in the editor_nodes field. This field is of type many-to-any. The JSON document only stores the junction items of these many-to-any relations (see examples below).

The following custom nodes are available:

Node type

Category

buttons

relation block

callouts

relation block

images

relation block

content_lists

relation block

related_content_lists

relation block

mediaplayers

relation block

mentions

relation inline block

Descriptions and data structure examples for each custom node are provided below.

A simple button component. Can have several props for styling.


Node as stored in the content document tree:

{
    "type": "relation-block",
    "attrs": {
        "id": "5689a87a-f2e8-4496-a63d-e5d359213305",
        "junction": "articles_editor_nodes",
        "collection": "buttons"
    }
}


Type definition of the related buttons collection:

import { type User } from '@directus/types'

interface Button {
  /**
   * uuid
   */
  id: string;

  /**
   * Color variant of the button
   */
  color: 'primary' | 'secondary' | 'tertiary' | null;

  /**
   * Iconify icon formatted as `{library}:{icon-name}` (e.g. `mdi:home`)
   *
   * Possible library values:
   * - mdi
   * - heroicons
   * - bxl
   * - material-symbols
   */
  icon: string | null;

  /**
   * Position of the icon within the button
   */
  icon_position: 'leading' | 'trailing' | null;

  /**
   * Whether the button links to an internal or external location
   */
  internal: boolean;

  /**
   * URL or internal path the button links to
   */
  to: string;

  /**
   * Text to display on the button
   */
  label: string;

  /**
   * Style variant of the button
   */
  variant: 'solid' | 'soft' | 'subtle' | 'outline' | 'ghost' | 'link';

  /**
   * Size variant of the button
   */
  size: 'xs' | 'sm' | 'md' | 'lg' | 'xl';

  /**
   * timestamp
   */
  date_created: string | null;

  /**
   * timestamp
   */
  date_updated: string | null;

  /**
   * User who created the item.
   */
  user_created: User | User['id'] | null;

  /**
   * User who last updated the item.
   */
  user_updated: User | User['id'] | null;
}

Components to highlight certain content within a semantic context. Callouts are unique in that they contain their own nested content documents.

Node as stored in the content document tree:

{
    "type": "relation-block",
    "attrs": {
        "id": "416a3276-068e-4d02-8737-fcd6fa73e03d",
        "junction": "articles_editor_nodes",
        "collection": "callouts"
    }
}

Type definition of the related callouts collection:

import type { User } from '@directus/types';
import type { JSONContent } from '@tiptap/core';

interface Callout {
  /**
   * UUID of the callout.
   */
  id: string;

 /**
   * Title displayed at the top of the callout.
   */
  title: string | null;

  /**
   * Color variant of the callout.
   */
  color:
    | 'primary'
    | 'secondary'
    | 'tertiary'
    | 'info'
    | 'success'
    | 'warning'
    | 'error'
    | 'neutral';

  /**
   * Rich text description stored as a Tiptap JSON document.
   */
  description: JSONContent | null;

  /**
   * Whether the callout can be dismissed by the user.
   */
  dismissable: boolean | null;

  /**
   * Parsed Tiptap editor nodes for the callout description.
   * These nodes are Many-to-Any junction items.
   */
  editor_nodes: CalloutsEditorNode[] | null;

  /**
   * Icon name in Iconify format.
   */
  icon: string | null;

  /**
   * Variant of the callout appearance.
   */
  variant: 'solid' | 'soft' | 'outline' | 'subtle' | null;

   /**
   * List of button actions associated with the callout.
   */
  actions: {
    label: string | null;
    internal: boolean;
    to: string | null;
    variant: 'solid' | 'soft' | 'outline' | 'subtle' | null;
  }[] | null;

  /**
   * User who created the item.
   */
  user_created: User | User['id'] | null;

  /**
   * User who last updated the item.
   */
  user_updated: User | User['id'] | null;

  /**
   * Timestamp indicating when the item was created.
   */
  date_created: string | null;

  /**
   * Timestamp indicating when the item was last updated.
   */
  date_updated: string | null;
}


interface CalloutsEditorNode {
   id: string;
   collection: string | null;
   callouts_id: Callout | Callout["id"] | null;
   item: Mention | Mention["id"] | null; // See Mentions
}

An image component.

Node as stored in the content document tree:

{
    "type": "relation-block",
    "attrs": {
        "id": "3d0e3bf1-9ea3-47be-9dba-5a3b939ffcca",
        "junction": "articles_editor_nodes",
        "collection": "images"
    }
}


Type definition of the related images collection:

import type { User, File } from '@directus/types';

interface Image {
  /**
   * UUID of the image relation.
   */
  id: string;

  /**
   * Alternative text describing the image.
   */
  alt_text: string | null;

  /**
   * Whether the image should span the full content width.
   */
  full_width: boolean;

  /**
   * Reference to the uploaded image file.
   */
  image: File | File['id'] | null;

  /**
   * Maximum display width in pixels. Only used if full_width is false.
   */
  max_width: number | null;

  /**
   * User who created the item.
   */
  user_created: User | User['id'] | null;

  /**
   * User who last updated the item.
   */
  user_updated: User | User['id'] | null;

  /**
   * Timestamp indicating when the item was created.
   */
  date_created: string | null;

  /**
   * Timestamp indicating when the item was last updated.
   */
  date_updated: string | null;
}

A component showing customisable and actionable lists. Contains a many-to-one relation to the collection content_list_items.

Node as stored in the content document tree:

{
    "type": "relation-block",
    "attrs": {
        "id": "036e6c31-9372-4bce-bd52-20e9f157bdc4",
        "junction": "articles_editor_nodes",
        "collection": "content_lists"
    }
}

Type definition of the related content_lists collection:

import type { User, File } from '@directus/types';

interface ContentList {
  /**
   * UUID of the content list.
   */
  id: string;

  /**
   * Items belonging to the content list.
   */
  items: ContentListItem[] | null;

  /**
   * User who created the item.
   */
  user_created: User | User['id'] | null;

  /**
   * User who last updated the item.
   */
  user_updated: User | User['id'] | null;

  /**
   * Timestamp indicating when the item was created.
   */
  date_created: string | null;

  /**
   * Timestamp indicating when the item was last updated.
   */
  date_updated: string | null;
}

interface ContentListItem {
  /**
   * UUID of the list item.
   */
  id: string;

   /**
   * Title of the content list item.
   */
  title: string | null;

  /**
   * Description text displayed for the item.
   */
  description: string | null;

  /**
   * Icon name in Iconify format.
   */
  icon: string | null;

  /**
   * Image file associated with the item.
   */
  image: File | File['id'] | null;

  /**
   * Whether the link points to an internal location.
   */
  internal: boolean;

  /**
   * URL or internal path for the item.
   */
  link: string | null;

  /**
   * Type of media displayed with the item.
   */
  mediatype: 'none' | 'icon' | 'image' | null;

  /**
   * Sort order within the content list.
   */
  sort: number | null;

   /**
   * Reference to the parent content list.
   */
  content_lists: ContentList | ContentList['id'] | null;
}

A component showing a list of content from other collections in the system. Contains a many-to-any field items which holds the junction to the related content items. Currently these related collections are supported (more might be added in the future, so make sure your implementation is forwards compatible):

  • articles

  • testimonials

  • videos

  • podcast_episodes

  • podcast_shows

  • sectors

  • programs

  • educational_institutions

Node as stored in the content document tree:

{
    "type": "relation-block",
    "attrs": {
        "id": "fc43e135-5c84-4a41-836a-e066cca08c58",
        "junction": "articles_editor_nodes",
        "collection": "related_content_lists"
    }
}


Type definition of the related related_content_lists collection:

import type { User } from '@directus/types';

interface RelatedContentList {
  /**
   * UUID of the related content list.
   */
  id: string;

  /**
   * Items belonging to this related content list.
   */
  items: RelatedContentListsItem[] | null;

  /**
   * User who created the item.
   */
  user_created: User | User['id'] | null;

  /**
   * User who last updated the item.
   */
  user_updated: User | User['id'] | null;

  /**
   * Timestamp indicating when the item was created.
   */
  date_created: string | null;

  /**
   * Timestamp indicating when the item was last updated.
   */
  date_updated: string | null;
}

/** Many-to-any junction item */
export interface RelatedContentListsItem {
  /**
   * Identifier of the related content list item.
   */
  id: number;

  /**
   * Name of the collection the related item belongs to.
   */
  collection: string | null;

  /**
   * Reference to the related item in its respective collection.
   * See Data model documentation for more info on these types
   */
  item:
    | Article
    | Article['id']
    | Testimonial
    | Testimonial['id']
    | Video
    | Video['id']
    | PodcastEpisode
    | PodcastEpisode['id']
    | PodcastShow
    | PodcastShow['id']
    | Sector
    | Sector['id']
    | Program
    | Program['id']
    | EducationalInstitution
    | EducationalInstitution['id']
    | RegionalEducationDesk
    | RegionalEducationDesk['id']
    | null;

  /**
   * Reference to the parent related content list.
   */
  related_content_lists_id:
    | RelatedContentList
    | RelatedContentList['id']
    | null;

  /**
   * Sort order within the related content list.
   */
  sort: number | null;
}

A mediaplayer component. Can be used to play either video or audio from a Cloudinary resource.

Node as stored in the content document tree:

{
    "type": "relation-block",
    "attrs": {
        "id": "ff94c5cf-2ed2-4378-86d6-a90bb5108a53",
        "junction": "articles_editor_nodes",
        "collection": "mediaplayers"
    }
}

Type definition of the related mediaplayers collection:

import type { User, File } from '@directus/types';

interface MediaplayerChapter {
  /**
   * Display label of the chapter.
   */
  label: string;

  /**
   * Start time of the chapter in seconds.
   */
  time: number;
}

interface Mediaplayer {
    /**
     * UUID of the mediaplayer item.
     */
    id: string;

    /**
     * Title of the media item.
     */
    title: string;

    /**
     * Artist name.
     */
    artist: string;

    /**
     * Whether the media element should span the full content width.
     */
    full_width: boolean;

    /**
     * Maximum display width in pixels. Only used if full_width is false.
     */
    max_width: number | null;

    /**
     * Type of media.
     */
    type: 'video' | 'audio';

    /**
     * Audio source configuration (audio only).
     */
    audio: {
        service: 'directus'  // Other services are not supported currently
        source: File['id'];
    } | null;

    /**
     * Video source configuration (video only).
     */
    video: {
        service: 'directus'  // Other services are not supported currently
        id: File['id'];
    } | null;

    /**
    * Artwork image for audio files.
    */
    artwork: File | File['id'] | null;

    /**
     * Chapter list for video files.
     */
    chapters: MediaplayerChapter[] | null;

    /**
     * User who created the item.
     */
    user_created: User | User['id'] | null;

    /**
     * User who last updated the item.
     */
    user_updated: User | User['id'] | null;

    /**
     * Timestamp indicating when the item was created.
     */
    date_created: string | null;

    /**
     * Timestamp indicating when the item was last updated.
     */
    date_updated: string | null;
}

An inline mention-like reference to another content item in the system. Mentions are mainly used to prevent the need for static /path references when using internal links. Currently, these related collections can be ‘mentioned’ (more might be added in the future, so make sure your implementation is forwards compatible):

  • articles

In contrast to relation block nodes, the relation inline block in not necessarily a top-level node, but usually a child of a paragraph node:

{
    "type": "paragraph",
    "content": [
        {
            "type": "text",
            "text": "An "
        },
        {
            "type": "text",
            "marks": [
                {
                    "type": "italic"
                }
            ],
            "text": "inline"
        },
        {
            "type": "text",
            "text": " "
        },
        {
            "type": "relation-inline-block",
            "attrs": {
                "id": "e6f88b38-05b4-4c29-bf77-9267b4e182b4",
                "junction": "articles_editor_nodes",
                "collection": "mentions"
            }
        },
        {
            "type": "text",
            "text": " block."
        }
    ]
}

Type definition of the related mentions collection:

import { User } from '@directus/types';

interface Mention {
    /**
     * UUID of the mention.
     */
    id: string;

    /**
     * Label inserted into the text. If empty, use the referenced item's title.
     */
    title: string | null;

    /**
     * Referenced item for this mention. Only one item is allowed.
     * Directus does not support one-to-any relationships so the
     * related items are stored in an array.
     * 
     * Should only have one item in the array, but in case of 
     * multiple items, only use the first one.
     */
    item: MentionsItem[] | null;

    /**
     * User who created the item.
     */
    user_created: User | User['id'] | null;

    /**
     * User who last updated the item.
     */
    user_updated: User | User['id'] | null;

    /**
     * Timestamp indicating when the item was created.
     */
    date_created: string | null;

    /**
     * Timestamp indicating when the item was last updated.
     */
    date_updated: string | null;
}

export interface MentionsItem {
    /**
     * Identifier of the mention item.
     */
    id: number;

    /**
     * Name of the referenced collection.
     */
    collection: string | null;

    /**
     * Referenced item within the collection.
     * Currently only articles are supported.
     */
    item: Article | Article['id'] | null;

    /**
     * Reference to the parent mention.
     */
    mentions_id: Mention | Mention['id'] | null;
}

Fetching and injecting relational data

When a document field is fetched, the resulting JSON contains only the junction references for custom nodes. The related item data is not nested inside the document itself. This ensures that related items remain independently updatable—embedding their data directly in the JSON document would prevent this.

To resolve these relations, the referenced items are stored in the editor_nodes field and can be fetched alongside the document. The following example demonstrates how to fetch each custom node in a type-safe way using the Directus SDK:

import { createDirectus, rest, staticToken, readItems, type QueryFields } from '@directus/sdk'

const API_TOKEN = 'my-static-token'
const DIRECTUS_URL = 'https://api.v2.onderwijsloket.com'


// Import type definitions from the Onderwijsloket API
import type {
	Schema, // Represents the Directus schema
	Button,
	Callout,
	Image,
	Mediaplayer,
	ContentList,
	Mention,
} from '#schema'

/**
 * Fields to fetch for mentioned items
 */
const mentionedItems = {
	articles: ['id', 'title', 'path', 'slug', 'description', 'date_created'],
} as const

/**
 * List fields to fetch for different custom nodes
 */
const mentions: QueryFields<Schema, Mention> = [
	'id',
	'title',
	{
		item: [
			'id',
			'collection',
			{
				item: {
					articles: mentionedItems.articles,
				},
			},
		],
	},
]

const buttons: QueryFields<Schema, Button> = [
	'id',
	'label',
	'icon',
	'icon_position',
	'color',
	'size',
	'variant',
	'to',
	'internal',
]

const callouts: QueryFields<Schema, Callout> = [
	'id',
	'title',
	'icon',
	'dismissable',
	'color',
	'actions',
	'variant',
	'description',
    // Notice that callouts can also have editor nodes inside them
	{
		editor_nodes: [
			'id',
			'collection',
			{
				item: {
					mentions,
				},
			},
		],
	},
]

const images: QueryFields<Schema, Image> = [
	'id',
	'alt_text',
	'max_width',
	'full_width',
	{ image: ['id', 'width', 'height'] },
]

const mediaplayers: QueryFields<Schema, Mediaplayer> = [
	'id',
	'title',
	'artist',
	'type',
	'audio',
	'video',
	'artwork',
	'chapters',
	'full_width',
	'max_width',
]

const content_lists: QueryFields<Schema, ContentList> = [
	'id',
	{
		items: ['id', 'title', 'description', 'mediatype', 'image', 'icon', 'internal', 'link'],
	},
]

/**
 * All available custom nodes that can be rendered in the ContentRenderer
 */
const customNodes = {
	buttons,
	callouts,
	images,
	mediaplayers,
	content_lists,
	mentions,
}

const editorNodeFields = [
    'collection',
    'id',
    {
        item: customNodes,
    },
] as const


// Create a Directus client instance
const directus = createDirectus<Schema>(DIRECTUS_URL)
    .with(staticToken(API_TOKEN))
    .with(rest())

// Example: Fetch articles with editor nodes including custom fields
const response = await directus.request(
    readItems('articles', {
        fields: [
            'id',
            'body',
            {
				editor_nodes: editorNodeFields,
			},
        ],
        limit: 1
    })
)

This query returns the document structure referencing relational nodes in body, alongside the corresponding relational records in editor_nodes. Each relational node includes its data as a sibling rather than inline within the document.

Injecting relational data into the JSON document

The next step is merging the relational editor_nodes back into the JSON document. This requires recursively resolving relation blocks and nested documents.

Your next step is to inject the relational data into the JSON document, so that you have a single object to work with. Note that this logic needs to be recursive, since the documents can contain nested document.

import type { JSONContent } from '@tiptap/core'
import { generateText } from '@tiptap/core'
import Text from '@tiptap/extension-text'
import Heading from '@tiptap/extension-heading'
import Document from '@tiptap/extension-document'
import Link from '@tiptap/extension-link'
import Bold from '@tiptap/extension-bold'
import Italic from '@tiptap/extension-italic'
import Strike from '@tiptap/extension-strike'
import Code from '@tiptap/extension-code'

import type {
    ArticlesEditorNode // Or whatever collection you are currently working with
} from '#schema'

type EditorNode = Record<string, any>

/** Maximum recursion depth safeguard to prevent infinite loops */
const MAX_DEPTH = 5

/** List of Tiptap node types that represent relations */
const RELATION_NODE_TYPES = ['relation-block', 'relation-inline-block']

/**
 * Checks if a value is a valid Tiptap document
 */
const isDoc = (value: unknown): value is JSONContent =>
	!!value && typeof value === 'object' && (value as { type: string }).type === 'doc'

/**
 * Deep clone helper that works across all browsers
 * Falls back to JSON parse/stringify if structuredClone is unavailable
 */
const deepClone = <T>(obj: T): T => {
	if (typeof structuredClone !== 'undefined') {
		return structuredClone(obj)
	}
	return JSON.parse(JSON.stringify(obj)) as T
}

/**
 * Converts a given string into a URL-friendly slug.
 *
 * This function normalizes the input string by removing secondarys, diacritics,
 * and special characters. It also converts the string to lowercase, trims
 * whitespace, replaces spaces with dashes, and ensures there are no consecutive dashes.
 *
 * @param text - The input string to be slugified.
 * @returns A slugified version of the input string.
 */
function slugify(text: string): string {
	return text
		.toString()
		.normalize('NFD') // Normalize secondarys
		.replace(/[\u0300-\u036f]/g, '') // Remove diacritics
		.toLowerCase()
		.trim()
		.replace(/[^a-z0-9 -]/g, '') // Remove non-alphanumeric characters (except spaces)
		.replace(/\s+/g, '-') // Replace spaces with dashes
		.replace(/-+/g, '-') // Remove consecutive dashes
}

/**
 * Injects related `item` data into a Tiptap JSON document.
 *
 * This function resolves "relation-block" and "relation-inline-block" nodes
 * by attaching their corresponding relational `data` (from `EditorNode[]`).
 *
 * It also supports nested structures:
 * - If a related item contains its own Tiptap document (e.g. which is the case
 *   for callouts with `editor_nodes`), the function recurses into that document.
 * - If that related item also includes nested `editor_nodes`, these are added
 *   to the data pool for deeper lookups.
 *
 * Additionally generates IDs for heading nodes for navigation purposes (can be opted
 * out of).
 *
 * A global recursion depth safeguard (`MAX_DEPTH`) prevents runaway loops.
 *
 * @param data - The list of available relational nodes
 * @param document - The Tiptap JSON document to process
 * @param primaryKeyField - The field used to match relation IDs (defaults to "id")
 * @param itemField - The field inside each node containing the relational payload (defaults to "item")
 * @param depth - Current recursion depth (internal use)
 * @param generateHeadingIds - Whether we should assign slug IDs to headings
 */
const injectData = (
	data: EditorNode[],
	document?: JSONContent | null,
	primaryKeyField = 'id',
	itemField = 'item',
	depth = 0,
    generateHeadingIds = true,
): JSONContent | null => {
	/** Internal recursive walker */
	function walk(
		node: JSONContent | null,
		availableData: EditorNode[],
		nodeDepth: number,
		docDepth: number,
	): JSONContent | null {
		if (!node) return null

		// Stop runaway recursion
		if (docDepth > MAX_DEPTH) {
			console.warn(
				`[injectData] Maximum document recursion depth (${MAX_DEPTH}) reached. Skipping deeper injection.`,
				{
					type: node.type,
					nodeDepth,
					docDepth,
					nodeAttrs: node.attrs ?? null,
				},
			)
			return node
		}

		/** -------------------------
		 * Relation block / inline block
		 * -------------------------- */
		if (node.type && RELATION_NODE_TYPES.includes(node.type) && node.attrs?.id) {
			const related = availableData.find((n) => n[primaryKeyField] === node.attrs!.id)

			if (related?.[itemField]) {
				const itemData = deepClone(related[itemField]) as Record<string, unknown>

				// Handle nested docs inside related items
				for (const [key, value] of Object.entries(itemData)) {
					if (isDoc(value)) {
						const nestedData = Array.isArray(itemData.editor_nodes)
							? [...availableData, ...(itemData.editor_nodes as EditorNode[])]
							: availableData

						itemData[key] = injectData(
							nestedData,
							value,
							primaryKeyField,
							itemField,
							docDepth + 1,
                            generateHeadingIds,
						)
					}
				}

				node.attrs.data = itemData
			}
		}

		/** -------------------------
		 * Heading slugs
		 * -------------------------- */
		if (node.type === 'heading' && !node.attrs?.id && generateHeadingIds) {
			const text = generateText(node, [
				Document,
				Heading,
				Text,
				Link,
				Bold,
				Italic,
				Strike,
				Code,
			])

			node.attrs = {
				id: slugify(text),
				plainText: text,
				...node.attrs,
			}
		}

		/** -------------------------
		 * Relation marks
		 * -------------------------- */
		if (node.type === 'text' && Array.isArray(node.marks)) {
			node.marks = node.marks.map((mark) => {
				if (mark.type !== 'relation-mark' || !mark.attrs?.id) return mark

				const related = availableData.find((n) => n[primaryKeyField] === mark.attrs!.id)

				if (related?.[itemField]) {
					mark.attrs.data = deepClone(related[itemField])
				}

				return mark
			})
		}

		/** -------------------------
		 * Children
		 * -------------------------- */
		if (Array.isArray(node.content)) {
			// IMPORTANT: propagate the SAME availableData from this level
			node.content = node.content
				.map((child) => walk(child, availableData, nodeDepth + 1, docDepth))
				.filter(Boolean) as JSONContent[]
		}

		return node
	}

	return walk(document ?? null, data, 0, depth)
}

// Example usage with the data fetched in previous snippet
const data = injectData(response.editor_nodes as ArticlesEditorNode[], response.body)

Congratulations! 🥳 You now have a single data object containing the full document with all relational data resolved.

Rendering in web applications

Rendering content documents in a web application can be done in several ways, depending on your framework and how much control you need over the output. The examples below show common approaches and how to integrate custom nodes using callouts as an example.

Generating content as HTML

To output the document as static HTML, you can extend the earlier example using generateHTML and register any custom nodes alongside the base Tiptap extensions.

import { Node, generateHTML, type JSONContent } from '@tiptap/core'
import StarterKit from '@tiptap/starter-kit'
import { CalloutNode } from './CalloutNode'

/**
 * Callout node renders a styled container representing a Callout relation block.
 * The node carries metadata (color, variant, icon, dismissable, etc.)
 * resolved from the related `callouts` collection in Directus.
 */
export const CalloutNode = Node.create({
  name: 'callout',
  group: 'block',
  content: 'block*',

  /**
   * Attribute definitions derived from the Callout schema.
   */
  addAttributes() {
    return {
      id: { default: null },
      color: { default: null },
      variant: { default: null },
      icon: { default: null },
      dismissable: { default: null },
      title: { default: null },
    }
  },

  /**
   * Render the Callout as HTML.
   * Attributes allow styling and structural interpretation in your renderer.
   */
  renderHTML({ HTMLAttributes }) {
    return [
      'aside',
      {
        class: `callout ${HTMLAttributes.color ?? ''} ${HTMLAttributes.variant ?? ''}`,
        'data-id': HTMLAttributes.id ?? undefined,
        'data-icon': HTMLAttributes.icon ?? undefined,
        'data-title': HTMLAttributes.title ?? undefined,
        'data-dismissable': HTMLAttributes.dismissable ?? undefined,
      },
      0,
    ]
  },

  /**
   * Parse HTML back into a Callout node.
   */
  parseHTML() {
    return [
      {
        tag: 'aside.callout',
      },
    ]
  },
})

/**
 * Convert JSON document to HTML including custom nodes.
 */
export function toHtml(doc: JSONContent): string {
  return generateHTML(doc, [
    StarterKit, // Prefer importing individual extensions, not the StarterKit
    CalloutNode,
  ])
}

Create a Tiptap editor

For component-level control or custom rendering logic, you can initialize a Tiptap editor. This adds flexibility but also more runtime overhead. The example below uses React, though Tiptap supports many frameworks.

import React from 'react'
import { EditorContent, useEditor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import { NodeViewWrapper, NodeViewContent } from '@tiptap/react'
import { CalloutNode } from './CalloutNode'

/**
 * React component for displaying a Callout node in read-only mode.
 */
export function CalloutComponent({ node }: any) {
  const { id, color, variant, icon, dismissable, title } = node.attrs

  return (
    <NodeViewWrapper
      as="aside"
      className={`callout ${color ?? ''} ${variant ?? ''}`}
      data-id={id ?? undefined}
      data-icon={icon ?? undefined}
      data-title={title ?? undefined}
      data-dismissable={dismissable ?? undefined}
    >
      {title && <div className="callout-title">{title}</div>}

      {icon && (
        <div className="callout-icon">
          {/* Replace this with your Iconify renderer */}
          <span>{icon}</span>
        </div>
      )}

      <NodeViewContent className="callout-content" />

      {dismissable && (
        <button
          type="button"
          disabled
          className="callout-dismiss"
          aria-hidden="true"
        >
          ×
        </button>
      )}
    </NodeViewWrapper>
  )
}

/**
 * Register the node so Tiptap renders it using React.
 */
const CalloutReactNode = CalloutNode.extend({
  addNodeView() {
    return ({ node }) => {
      return React.createElement(CalloutComponent, { node })
    }
  },
})

/**
 * Read-only renderer for Tiptap documents including custom nodes.
 */
export function CalloutRenderer({ doc }: { doc: any }) {
  const editor = useEditor({
    editable: false,         // 👈 read-only mode
    content: doc,
    extensions: [
      StarterKit.configure({
        editable: false,
      }),
      CalloutReactNode,
    ],
  })

  return <EditorContent editor={editor} />
}

Write your own renderer

If you want full control over serialization and rendering without depending on Tiptap’s runtime, you can implement your own renderer. The structured nature of content documents makes this approach straightforward. The following Vue example provides a minimal starting point:

<script setup lang="ts">
import { h } from 'vue'
import type { JSONContent } from '@tiptap/core'

/**
 * Vue renderer for a subset of Tiptap JSONContent.
 * No Tiptap runtime involved.
 *
 * Supports:
 * - paragraph node
 * - text node (with bold mark)
 * - callout custom node
 */

function renderNode(node: JSONContent): any {
  switch (node.type) {
    case 'paragraph':
      return h('p', {}, renderChildren(node))

    case 'callout': {
      const attrs = node.attrs ?? {}
      const { color, variant, title, icon } = attrs

      return h(
        'aside',
        {
          class: ['callout', color, variant].filter(Boolean).join(' '),
        },
        [
          title ? h('div', { class: 'callout-title' }, title) : null,
          icon ? h('div', { class: 'callout-icon' }, icon) : null,
          ...renderChildren(node),
        ]
      )
    }

    case 'text':
      return renderTextNode(node)

    default:
      return renderChildren(node)
  }
}

function renderChildren(node: JSONContent): any[] {
  if (!node.content) return []
  return node.content.map(renderNode)
}

function renderTextNode(node: JSONContent): any {
  let children: any = node.text ?? ''

  if (node.marks) {
    for (const mark of node.marks) {
      if (mark.type === 'bold') {
        children = h('strong', {}, children)
      }
    }
  }

  return children
}

/**
 * Props
 */
const props = defineProps<{
  doc: JSONContent
}>()
</script>

<template>
  <div class="renderer-root">
    <!-- Render the JSONContent using h() -->
    <component :is="renderNode(doc)" />
  </div>
</template>

<style scoped>
.callout {
  padding: 1rem;
  border-radius: 6px;
  border-left: 4px solid var(--c-border);
  background: var(--c-bg-soft);
}

.callout.info {
  border-color: #3b82f6;
}

.callout-title {
  font-weight: bold;
  margin-bottom: 0.25rem;
}

.callout-icon {
  opacity: 0.75;
  margin-bottom: 0.25rem;
}
</style>

Find a tool that fits your needs

Because Tiptap is widely adopted, many open-source libraries exist that extend or build on top of it. These can simplify rendering, collaboration, or custom node handling depending on your application.

Usage across collections

Content documents appear in multiple collections, and not every document uses the same set of nodes. If you work across multiple collections, it is usually easiest to implement a single renderer that supports all node types you expect to encounter.

If you only work with one or two collections, you can limit your renderer to the nodes used in those specific documents. The following table outlines which nodes appear in which fields.

Collection

Field

Nodes

articles

body

Nodes: paragraph, h2, h3, h4, horizontalRule, bulletList, ListItem, OrderedList, Table

Marks: bold, italic, strike, subscript, superscript, link

Custom: buttons, callouts, content_lists, related_content_lists, images, mediaplayers, mentions

callouts

description

Nodes: paragraph, bulletList, ListItem, OrderedList

Marks: bold, italic, link

Custom: mentions

educational_
institutions

description

Nodes: paragraph, bulletList, ListItem, OrderedList

Marks: bold, italic, subscript, superscript, link

faqs

answer

Nodes: paragraph, bulletList, ListItem, OrderedList

Marks: bold, italic, link

podcast_episodes

body

Nodes: paragraph, h3, h4, bulletList, ListItem, OrderedList

Marks: bold, italic, link

podcast_shows

description

Nodes: paragraph

Marks: bold, italic, link

profiles

body

Nodes: paragraph, h3, bulletList, ListItem, OrderedList

Marks: bold, italic, subscript, superscript, link

program_forms

description

Nodes: paragraph, h2, h3, h4, h5, h6, bulletList, ListItem, OrderedList

Marks: bold, italic, subscript, superscript, link

regional_
education_desks

consultation_
service_description

Nodes: paragraph, h1, h2, h3, h4, h5, h6, horizontalRule, bulletList, ListItem, OrderedList, Table

Marks: bold, italic, strike, subscript, superscript, link

regional_
education_desks

description

Nodes: paragraph, h3, h4, h5, bulletList, ListItem, OrderedList

Marks: bold, italic

route_steps

body

Nodes: paragraph, h3, h4, h5, bulletList, ListItem, OrderedList

Marks: bold, italic, link

team_members

description

Nodes: paragraph, bulletList, ListItem, OrderedList

Marks: bold, italic, subscript, superscript, link

testimonials

body

Nodes: paragraph, h2, h3, h4, h5, h6, horizontalRule, bulletList, ListItem, OrderedList, Table

Marks: bold, italic, subscript, superscript, link

Custom: buttons, callouts, content_lists, related_content_lists, images, mediaplayers, mentions

themas

body

Nodes: paragraph, h3, h4, h5, h6, bulletList, ListItem, OrderedList

Marks: bold, italic, subscript, superscript, link

videos

body

Nodes: paragraph, h3, h4, bulletList, ListItem, OrderedList

Marks: bold, italic, link

Forwards compatibility

New core or custom nodes may be added in the future. If your renderer does not handle unknown node types gracefully, your application may break. Consider implementing one of these safeguards:

  • Filter unsupported nodes from the document.

  • Display a placeholder or warning when an unsupported node appears.

  • Fallback to rendering unsupported nodes as plain text by inspecting common properties such as text or content.

Choose the strategy that best fits your application and tolerance for unexpected content.