Je hebt Javascript nodig om deze website te kunnen gebruiken. Pas je browserinstellingen in om verder te gaan!
Miscellaneous

Content Documents

Fetch, resolve, and render rich Tiptap JSON documents and their relational nodes.

Content collections often expose a document field such as body plus editor_nodes. Together they represent a Tiptap document with relational data for custom nodes.

Parse the document JSON

Support the Tiptap nodes and marks that can occur in the field you use.

Fetch and inject relational nodes

Request editor_nodes, then merge the referenced data back into the JSON tree.

Choose a renderer

Render HTML, use a read-only Tiptap editor, or write a focused renderer for your interface.

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

Parse 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 (though it will likely work in most cases).

Resolve 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 typeCategory
buttonsrelation block
calloutsrelation block
imagesrelation block
content_listsrelation block
related_content_listsrelation block
mediaplayersrelation block
mentionsrelation 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;
}

Fetch and inject relational data

The document JSON contains junction references for custom nodes, not their related item data. The related records remain independently updateable in editor_nodes.

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://content.onderwijsloket.com";

// Type helper for field keys in Directus queries
import type { QueryFields } from "@directus/sdk";

// Import Directus schema types
import type {
  Schema, // Full Directus schema
  // Node types
  Button,
  Callout,
  Image,
  Mediaplayer,
  ContentList,
  Mention,
  RelatedContentList,

  // Related item types
  Article,
  Testimonial,
  Video,
  PodcastEpisode,
  PodcastShow,
  Sector,
  Program,
  EducationalInstitution,
  RegionalEducationDesk
} from "#schema";

/**
 * Fields to fetch for related content items. Used by both the Mention block
 * and the Related Content List block.
 */

const relatedArticles = [
  "id",
  "title",
  "path",
  "slug",
  "description",
  "date_created"
] satisfies QueryFields<Schema, Article>;
const relatedTestimonials = [
  "id",
  "title",
  "path",
  "slug",
  "description",
  "date_created",
  { profiles: [{ profiles_id: ["image"] }] }
] satisfies QueryFields<Schema, Testimonial>;
const relatedVideos = [
  "id",
  "title",
  "path",
  "slug",
  "video",
  "date_created"
] satisfies QueryFields<Schema, Video>;
const relatedPodcastEpisodes = [
  "id",
  "title",
  "path",
  "slug",
  "audio",
  "date_created",
  { show: ["artwork"] }
] satisfies QueryFields<Schema, PodcastEpisode>;
const relatedPodcastShow = [
  "id",
  "title",
  "path",
  "slug",
  "tagline",
  "artwork",
  "date_created"
] satisfies QueryFields<Schema, PodcastShow>;
const relatedSectors = ["id", "title", "slug", "description", "date_created"] satisfies QueryFields<
  Schema,
  Sector
>;
const relatedPrograms = [
  "id",
  "title",
  "slug",
  "path",
  "date_created",
  { educational_institutions: [{ educational_institutions_id: ["logo"] }] }
] satisfies QueryFields<Schema, Program>;
const relatedEducationalInstitutions = [
  "id",
  "title",
  "slug",
  "path",
  "logo",
  "date_created"
] satisfies QueryFields<Schema, EducationalInstitution>;
const relatedRegionalEducationDesks = [
  "id",
  "title",
  "slug",
  "path",
  "logo",
  "date_created"
] satisfies QueryFields<Schema, RegionalEducationDesk>;

const mentionedCollections = {
  articles: relatedArticles
};

const relatedContent = {
  articles: relatedArticles,
  testimonials: relatedTestimonials,
  videos: relatedVideos,
  podcast_episodes: relatedPodcastEpisodes,
  podcast_shows: relatedPodcastShow,
  sectors: relatedSectors,
  programs: relatedPrograms,
  educational_institutions: relatedEducationalInstitutions,
  regional_education_desks: relatedRegionalEducationDesks
};

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

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"]
  }
];

const related_content_lists: QueryFields<Schema, RelatedContentList> = [
  "id",
  {
    items: [
      "collection",
      {
        item: relatedContent
      }
    ]
  }
];

/**
 * All available custom nodes that can be rendered in the ContentRenderer
 */
const customNodes = {
  buttons,
  callouts,
  images,
  mediaplayers,
  content_lists,
  related_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
  })
);
You can also find all editor node definitions in the code snippets repository.

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

Merge editor_nodes into the document to create one renderable object. The operation must be recursive because a relation can contain a 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);

You now have one data object containing the full document and its resolved relational data.

You can also find this function, along with related examples, in the code snippets repository.

Handle stale junctions

A content document may reference nodes whose junction IDs are no longer present in the editor_nodes field. This typically occurs when a related item is deleted and the backend does not properly sanitize the content document.

To prevent issues in these situations, filter out any custom nodes in the document that no longer have a corresponding entry in editor_nodes.

Render in a web application

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.

Generate 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
  ]);
}

Support the collections you use

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.

CollectionFieldNodes
articlesbodyNodes: 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
calloutsdescriptionNodes: paragraph, bulletList, ListItem, OrderedList Marks: bold, italic, link Custom: mentions
educational_ institutionsdescriptionNodes: paragraph, bulletList, ListItem, OrderedList Marks: bold, italic, subscript, superscript, link
faqsanswerNodes: paragraph, bulletList, ListItem, OrderedList Marks: bold, italic, link
podcast_episodesbodyNodes: paragraph, h3, h4, bulletList, ListItem, OrderedList Marks: bold, italic, link
podcast_showsdescriptionNodes: paragraph Marks: bold, italic, link
profilesbodyNodes: paragraph, h3, bulletList, ListItem, OrderedList Marks: bold, italic, subscript, superscript, link
program_formsdescriptionNodes: paragraph, h2, h3, h4, h5, h6, bulletList, ListItem, OrderedList Marks: bold, italic, subscript, superscript, link
regional_ education_desksconsultation_ service_descriptionNodes: paragraph, h1, h2, h3, h4, h5, h6, horizontalRule, bulletList, ListItem, OrderedList, Table Marks: bold, italic, strike, subscript, superscript, link
regional_ education_desksdescriptionNodes: paragraph, h3, h4, h5, bulletList, ListItem, OrderedList Marks: bold, italic
route_stepsbodyNodes: paragraph, h3, h4, h5, bulletList, ListItem, OrderedList Marks: bold, italic, link
team_membersdescriptionNodes: paragraph, bulletList, ListItem, OrderedList Marks: bold, italic, subscript, superscript, link
testimonialsbodyNodes: 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
themasbodyNodes: paragraph, h3, h4, h5, h6, bulletList, ListItem, OrderedList Marks: bold, italic, subscript, superscript, link
videosbodyNodes: paragraph, h3, h4, bulletList, ListItem, OrderedList Marks: bold, italic, link

Plan for 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.

Keep in touch with the latest

Sign up for our monthly deep dives - straight to your inbox.