Rich Text Rendering with Contentful and Node.js
A practical guide to rendering Contentful Rich Text in Node.js covering custom node renderers, embedded entries and assets, image optimization, and Express.js integration.
Rich Text Rendering with Contentful and Node.js
If you have worked with Contentful for any amount of time, you have probably encountered both Markdown fields and Rich Text fields. Markdown is simple, portable, and predictable. Rich Text is none of those things — but it is significantly more powerful. It gives you structured JSON instead of a flat string, which means you can embed entries, embed assets, link to other content types, and build content experiences that Markdown simply cannot support.
The tricky part is rendering it. Contentful does not store HTML. It stores a deeply nested JSON document tree, and it is your job to walk that tree and turn it into whatever output format your application needs. For most Node.js applications, that means HTML.
This article covers how to do that properly. We will build a working Express.js blog renderer that fetches Rich Text content from Contentful and renders it with custom handlers for embedded images, code blocks, linked entries, and more.
Understanding the Rich Text Document Structure
A Rich Text field in Contentful stores content as a structured JSON document. The top-level object has a nodeType of "document" and contains an array of content nodes. Each node has its own nodeType, optional marks, optional data, and potentially its own nested content array.
Here is a simplified example of what a Rich Text document looks like:
{
"nodeType": "document",
"data": {},
"content": [
{
"nodeType": "heading-1",
"data": {},
"content": [
{
"nodeType": "text",
"value": "Getting Started",
"marks": [],
"data": {}
}
]
},
{
"nodeType": "paragraph",
"data": {},
"content": [
{
"nodeType": "text",
"value": "This is a paragraph with ",
"marks": [],
"data": {}
},
{
"nodeType": "text",
"value": "bold text",
"marks": [{ "type": "bold" }],
"data": {}
},
{
"nodeType": "text",
"value": " and ",
"marks": [],
"data": {}
},
{
"nodeType": "text",
"value": "inline code",
"marks": [{ "type": "code" }],
"data": {}
}
]
},
{
"nodeType": "embedded-asset-block",
"data": {
"target": {
"sys": {
"id": "asset123",
"type": "Link",
"linkType": "Asset"
}
}
},
"content": []
}
]
}
The key node types you will encounter are: paragraph, heading-1 through heading-6, unordered-list, ordered-list, list-item, blockquote, hr, table, table-row, table-cell, table-header-cell, hyperlink, embedded-entry-block, embedded-entry-inline, embedded-asset-block, and text.
Marks are inline formatting applied to text nodes: bold, italic, underline, code, superscript, and subscript.
This is fundamentally different from Markdown. In Markdown, you get a flat string and parse it into HTML. With Rich Text, you get a pre-parsed AST and you need to render it. The structure is already there — you just need to decide what each node becomes.
Setting Up the Renderer
The official library for converting Rich Text to HTML is @contentful/rich-text-html-renderer. Install it alongside the Contentful client and the Rich Text types package:
npm install contentful @contentful/rich-text-html-renderer @contentful/rich-text-types
The basic usage is straightforward:
var contentful = require("contentful");
var { documentToHtmlString } = require("@contentful/rich-text-html-renderer");
var client = contentful.createClient({
space: process.env.CONTENTFUL_SPACE,
accessToken: process.env.CONTENTFUL_TOKEN
});
client.getEntry("your-entry-id").then(function (entry) {
var html = documentToHtmlString(entry.fields.body);
console.log(html);
});
Without any options, documentToHtmlString produces basic HTML. Headings become <h1> through <h6>, paragraphs become <p>, lists become <ul> and <ol>, and so on. But embedded entries and embedded assets render as empty strings by default. That is where custom renderers come in.
Custom Node Renderers
The second argument to documentToHtmlString is an options object with a renderNode property. Each key in renderNode maps a node type to a function that receives the node and a next callback for rendering child content.
var { BLOCKS, INLINES } = require("@contentful/rich-text-types");
var renderOptions = {
renderNode: {
[BLOCKS.EMBEDDED_ASSET]: function (node) {
var asset = node.data.target;
var url = asset.fields.file.url;
var title = asset.fields.title || "";
var description = asset.fields.description || "";
return '<img src="https:' + url + '" alt="' + description + '" title="' + title + '" />';
},
[BLOCKS.EMBEDDED_ENTRY]: function (node) {
var entry = node.data.target;
var contentType = entry.sys.contentType.sys.id;
if (contentType === "codeBlock") {
return '<pre><code class="language-' + entry.fields.language + '">'
+ escapeHtml(entry.fields.code)
+ "</code></pre>";
}
if (contentType === "callout") {
return '<div class="callout callout-' + entry.fields.type + '">'
+ '<p>' + entry.fields.message + '</p>'
+ '</div>';
}
return "";
},
[INLINES.EMBEDDED_ENTRY]: function (node) {
var entry = node.data.target;
var contentType = entry.sys.contentType.sys.id;
if (contentType === "person") {
return '<span class="person-mention">@' + entry.fields.name + '</span>';
}
return "";
},
[INLINES.HYPERLINK]: function (node, next) {
var uri = node.data.uri;
var isExternal = uri.indexOf("://") !== -1;
var attrs = isExternal ? ' target="_blank" rel="noopener noreferrer"' : "";
return '<a href="' + uri + '"' + attrs + '>' + next(node.content) + '</a>';
}
}
};
function escapeHtml(str) {
return str
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """);
}
var html = documentToHtmlString(richTextDocument, renderOptions);
A few things to note here. The next callback is critical for nodes that contain child content, like hyperlinks. If you forget to call next(node.content), the link text disappears. For leaf nodes like embedded assets and entries, there is no child content to render, so you do not need next.
Custom Mark Renderers
You can also customize how inline marks render with the renderMark option:
var { MARKS } = require("@contentful/rich-text-types");
var renderOptions = {
renderMark: {
[MARKS.BOLD]: function (text) {
return "<strong>" + text + "</strong>";
},
[MARKS.ITALIC]: function (text) {
return "<em>" + text + "</em>";
},
[MARKS.UNDERLINE]: function (text) {
return '<span class="underline">' + text + "</span>";
},
[MARKS.CODE]: function (text) {
return '<code class="inline-code">' + text + "</code>";
}
},
renderNode: {
// ... node renderers
}
};
This is useful when you want to apply CSS classes to inline elements or change the default HTML tags.
Responsive Images with the Contentful Images API
Contentful hosts assets on its CDN and provides an Images API that supports on-the-fly transformations. You should use this aggressively for embedded images. Serving a 4000-pixel-wide photograph in a blog post is wasteful when most readers are on screens 800 to 1200 pixels wide.
Here is an embedded asset renderer that generates responsive images with srcset:
[BLOCKS.EMBEDDED_ASSET]: function (node) {
var asset = node.data.target;
if (!asset || !asset.fields || !asset.fields.file) {
return '<p class="content-error">[Missing asset]</p>';
}
var file = asset.fields.file;
var url = "https:" + file.url;
var alt = asset.fields.description || asset.fields.title || "";
var contentType = file.contentType || "";
// Only apply image optimization to actual images
if (contentType.indexOf("image/") !== 0) {
return '<a href="' + url + '" download>' + (asset.fields.title || "Download file") + '</a>';
}
var widths = [400, 800, 1200];
var srcset = widths.map(function (w) {
return url + "?w=" + w + "&fm=webp&q=80 " + w + "w";
}).join(", ");
var fallbackSrc = url + "?w=800&fm=jpg&q=80";
var title = asset.fields.title || "";
return '<figure>'
+ '<picture>'
+ '<source type="image/webp" srcset="' + srcset + '" sizes="(max-width: 800px) 100vw, 800px" />'
+ '<img src="' + fallbackSrc + '" alt="' + escapeHtml(alt) + '" title="' + escapeHtml(title) + '" loading="lazy" />'
+ '</picture>'
+ (title ? '<figcaption>' + escapeHtml(title) + '</figcaption>' : '')
+ '</figure>';
}
The ?w= parameter resizes the image, &fm=webp converts to WebP format, and &q=80 sets the quality. The <picture> element with a WebP source and a JPEG fallback gives you broad browser support with modern compression. The loading="lazy" attribute defers off-screen images, which matters for long articles.
Rendering Tables
Rich Text tables are represented as table nodes containing table-row nodes, which contain either table-header-cell or table-cell nodes. Each cell contains its own content array, so a cell can hold paragraphs, lists, or even embedded entries.
[BLOCKS.TABLE]: function (node, next) {
return '<div class="table-responsive"><table class="table">' + next(node.content) + '</table></div>';
},
[BLOCKS.TABLE_ROW]: function (node, next) {
return '<tr>' + next(node.content) + '</tr>';
},
[BLOCKS.TABLE_HEADER_CELL]: function (node, next) {
return '<th>' + next(node.content) + '</th>';
},
[BLOCKS.TABLE_CELL]: function (node, next) {
return '<td>' + next(node.content) + '</td>';
}
Wrapping the table in a div with overflow-x: auto prevents horizontal overflow on mobile screens. This is a small detail that makes a big difference.
Linked Entry Resolution
Here is a subtlety that catches people off guard. When you fetch an entry from Contentful, embedded entries and assets in Rich Text fields are not automatically resolved. By default, you get link objects with just the sys.id, not the full field data.
You need to set the include parameter on your query to control the depth of linked entry resolution:
client.getEntries({
content_type: "blogPost",
include: 3,
"fields.slug": slug
}).then(function (response) {
var entry = response.items[0];
// Now embedded entries and assets have their fields resolved
var html = documentToHtmlString(entry.fields.body, renderOptions);
});
The include parameter goes from 1 to 10 and determines how many levels deep Contentful resolves linked entries. For most blog content, include: 3 is sufficient. If you have deeply nested content models — say, an article that embeds a section that embeds a component — you may need to go higher.
If a linked entry is not resolved (because the include depth was too shallow, or the entry was deleted, or it is in draft status), node.data.target will either be undefined or contain only the sys object without fields. Always check for this in your renderers.
Complete Express.js Blog Renderer
Let us put everything together into a working Express.js application. This example fetches blog posts with Rich Text content from Contentful and renders them using Pug templates.
// app.js
var express = require("express");
var contentful = require("contentful");
var { documentToHtmlString } = require("@contentful/rich-text-html-renderer");
var { BLOCKS, INLINES, MARKS } = require("@contentful/rich-text-types");
var sanitizeHtml = require("sanitize-html");
var app = express();
app.set("view engine", "pug");
app.set("views", "./views");
app.use(express.static("public"));
var client = contentful.createClient({
space: process.env.CONTENTFUL_SPACE,
accessToken: process.env.CONTENTFUL_TOKEN
});
// --- HTML escaping utility ---
function escapeHtml(str) {
if (!str) return "";
return str
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """);
}
// --- Rich Text render options ---
var renderOptions = {
renderMark: {
[MARKS.BOLD]: function (text) { return "<strong>" + text + "</strong>"; },
[MARKS.ITALIC]: function (text) { return "<em>" + text + "</em>"; },
[MARKS.UNDERLINE]: function (text) { return '<span class="underline">' + text + "</span>"; },
[MARKS.CODE]: function (text) { return '<code class="inline-code">' + text + "</code>"; }
},
renderNode: {
[BLOCKS.EMBEDDED_ASSET]: function (node) {
var asset = node.data.target;
if (!asset || !asset.fields || !asset.fields.file) {
console.warn("Unresolved asset in Rich Text:", node.data);
return '<p class="content-error">[Image unavailable]</p>';
}
var file = asset.fields.file;
var url = "https:" + file.url;
var alt = asset.fields.description || asset.fields.title || "";
if ((file.contentType || "").indexOf("image/") !== 0) {
return '<a href="' + url + '">' + escapeHtml(asset.fields.title || "Download") + '</a>';
}
var widths = [400, 800, 1200];
var srcset = widths.map(function (w) {
return url + "?w=" + w + "&fm=webp&q=80 " + w + "w";
}).join(", ");
var fallback = url + "?w=800&fm=jpg&q=80";
var title = asset.fields.title || "";
return '<figure class="article-figure">'
+ '<picture>'
+ '<source type="image/webp" srcset="' + srcset + '" sizes="(max-width: 800px) 100vw, 800px" />'
+ '<img src="' + fallback + '" alt="' + escapeHtml(alt) + '" title="' + escapeHtml(title) + '" loading="lazy" />'
+ '</picture>'
+ (title ? '<figcaption>' + escapeHtml(title) + '</figcaption>' : '')
+ '</figure>';
},
[BLOCKS.EMBEDDED_ENTRY]: function (node) {
var entry = node.data.target;
if (!entry || !entry.fields) {
console.warn("Unresolved embedded entry:", node.data);
return "";
}
var contentType = entry.sys.contentType.sys.id;
if (contentType === "codeBlock") {
var lang = entry.fields.language || "text";
var code = entry.fields.code || "";
var filename = entry.fields.filename || "";
return '<div class="code-block">'
+ (filename ? '<div class="code-filename">' + escapeHtml(filename) + '</div>' : '')
+ '<pre><code class="language-' + escapeHtml(lang) + '">'
+ escapeHtml(code)
+ '</code></pre>'
+ '</div>';
}
if (contentType === "entryCard") {
return '<div class="entry-card">'
+ '<h3><a href="' + (entry.fields.url || "#") + '">' + escapeHtml(entry.fields.title || "") + '</a></h3>'
+ '<p>' + escapeHtml(entry.fields.summary || "") + '</p>'
+ '</div>';
}
return "";
},
[INLINES.EMBEDDED_ENTRY]: function (node) {
var entry = node.data.target;
if (!entry || !entry.fields) return "";
var contentType = entry.sys.contentType.sys.id;
if (contentType === "inlineReference") {
return '<a href="' + (entry.fields.url || "#") + '" class="inline-ref">'
+ escapeHtml(entry.fields.label || "link") + '</a>';
}
return "";
},
[INLINES.HYPERLINK]: function (node, next) {
var uri = node.data.uri;
var isExternal = uri.indexOf("://") !== -1
&& uri.indexOf(process.env.SITE_DOMAIN || "example.com") === -1;
var attrs = isExternal ? ' target="_blank" rel="noopener noreferrer"' : '';
return '<a href="' + uri + '"' + attrs + '>' + next(node.content) + '</a>';
},
[BLOCKS.TABLE]: function (node, next) {
return '<div class="table-responsive"><table class="table">' + next(node.content) + '</table></div>';
},
[BLOCKS.TABLE_ROW]: function (node, next) {
return '<tr>' + next(node.content) + '</tr>';
},
[BLOCKS.TABLE_HEADER_CELL]: function (node, next) {
return '<th>' + next(node.content) + '</th>';
},
[BLOCKS.TABLE_CELL]: function (node, next) {
return '<td>' + next(node.content) + '</td>';
}
}
};
// --- Sanitization layer ---
function renderRichText(document) {
if (!document || document.nodeType !== "document") {
return "";
}
var rawHtml = documentToHtmlString(document, renderOptions);
return sanitizeHtml(rawHtml, {
allowedTags: sanitizeHtml.defaults.allowedTags.concat([
"img", "figure", "figcaption", "picture", "source",
"pre", "code", "table", "thead", "tbody", "tr", "th", "td",
"span", "div", "h1", "h2", "h3", "h4", "h5", "h6"
]),
allowedAttributes: {
a: ["href", "target", "rel", "class"],
img: ["src", "alt", "title", "loading", "srcset", "sizes"],
source: ["type", "srcset", "sizes"],
code: ["class"],
div: ["class"],
span: ["class"],
td: ["colspan", "rowspan"],
th: ["colspan", "rowspan"]
},
allowedSchemes: ["http", "https", "mailto"]
});
}
// --- Routes ---
app.get("/", function (req, res) {
client.getEntries({
content_type: "blogPost",
order: "-fields.publishDate",
limit: 10,
include: 1
}).then(function (response) {
var posts = response.items.map(function (item) {
return {
title: item.fields.title,
slug: item.fields.slug,
synopsis: item.fields.synopsis,
publishDate: item.fields.publishDate,
image: item.fields.heroImage
? "https:" + item.fields.heroImage.fields.file.url + "?w=400&fm=webp&q=75"
: null
};
});
res.render("index", { posts: posts });
}).catch(function (err) {
console.error("Error fetching posts:", err);
res.status(500).render("error", { message: "Failed to load posts" });
});
});
app.get("/article/:slug", function (req, res) {
client.getEntries({
content_type: "blogPost",
"fields.slug": req.params.slug,
include: 3,
limit: 1
}).then(function (response) {
if (!response.items.length) {
return res.status(404).render("error", { message: "Article not found" });
}
var entry = response.items[0];
var articleHtml = renderRichText(entry.fields.body);
res.render("article", {
title: entry.fields.title,
content: articleHtml,
publishDate: entry.fields.publishDate,
author: entry.fields.author ? entry.fields.author.fields : null
});
}).catch(function (err) {
console.error("Error fetching article:", err);
res.status(500).render("error", { message: "Failed to load article" });
});
});
var port = process.env.PORT || 3000;
app.listen(port, function () {
console.log("Server running on port " + port);
});
And the corresponding Pug template for the article page:
//- views/article.pug
extends layout
block content
article.blog-article
h1= title
.article-meta
if author
span.author= author.name
time(datetime=publishDate)= new Date(publishDate).toLocaleDateString()
.article-body
!= content
link(rel="stylesheet" href="https://cdn.jsdelivr.net/npm/prismjs@1/themes/prism-tomorrow.min.css")
script(src="https://cdn.jsdelivr.net/npm/prismjs@1/prism.min.js")
script(src="https://cdn.jsdelivr.net/npm/prismjs@1/plugins/autoloader/prism-autoloader.min.js")
The != content in Pug outputs raw HTML without escaping, which is necessary since we already rendered and sanitized the Rich Text. The Prism.js scripts at the bottom handle syntax highlighting for any code blocks.
Rich Text vs Markdown Fields
When should you use Rich Text instead of Markdown? Here is my take after running both in production.
Use Markdown when your content is straightforward text with basic formatting, when your authors are developers comfortable with Markdown syntax, when you want maximum portability (Markdown works everywhere), or when you do not need embedded entries or assets within the content body.
Use Rich Text when you need to embed other Contentful entries inline (product cards, CTAs, author bios), when you need structured references that survive content model changes, when your content editors are non-technical and need a WYSIWYG experience, or when you want Contentful to enforce content structure at the field level.
The overhead of Rich Text is real. You need the renderer library, you need custom renderers for every embedded content type, and the JSON documents are larger than equivalent Markdown. But the structured nature of Rich Text means you can change how embedded content renders without touching the content itself. That is a significant advantage for long-lived content.
Performance Considerations
Rich Text documents can get large. A 3000-word article with embedded images, code blocks, and tables produces a deeply nested JSON tree. The documentToHtmlString function walks this tree recursively, and for most articles, it finishes in under 5 milliseconds. But there are a few things to watch for.
First, cache the rendered HTML. If you are rendering the same article on every request, you are wasting CPU cycles. Use a simple in-memory cache with a TTL, or cache at the CDN level:
var NodeCache = require("node-cache");
var htmlCache = new NodeCache({ stdTTL: 300 }); // 5 minute TTL
function getCachedHtml(entryId, richTextDocument) {
var cached = htmlCache.get(entryId);
if (cached) return cached;
var html = renderRichText(richTextDocument);
htmlCache.set(entryId, html);
return html;
}
Second, be mindful of the include depth. Setting include: 10 on every query forces Contentful to resolve every linked entry to maximum depth, which increases response size and API latency. Start with include: 2 and increase only if you actually have deeper embedding.
Third, if you are rendering Rich Text at build time (static site generation), the performance of documentToHtmlString is irrelevant. Render once, deploy the HTML, and move on.
Common Issues and Troubleshooting
1. Embedded entries render as empty strings.
This is the most common issue. It happens when linked entries are not resolved in the API response. Check your include parameter. If it is set to 1 and your embedded entry is two levels deep, it will not be resolved. Increase include to 2 or 3. Also check that the embedded entry is actually published — draft entries are not included in delivery API responses.
2. node.data.target is undefined or missing fields.
This occurs when an embedded entry or asset has been deleted from Contentful but the Rich Text document still references it. The link is broken. Always add null checks in your renderers:
[BLOCKS.EMBEDDED_ENTRY]: function (node) {
var entry = node.data.target;
if (!entry || !entry.sys || !entry.fields) {
console.warn("Unresolved entry reference:", JSON.stringify(node.data));
return "";
}
// ... render logic
}
3. Images appear without the protocol prefix.
Contentful asset URLs start with //images.ctfassets.net/... (protocol-relative). If you render them without prepending https:, they may break in some contexts like email templates or non-browser environments. Always prefix with https:.
4. Rich Text content renders but Prism syntax highlighting does not activate.
Prism runs on DOMContentLoaded. If you load content dynamically after page load, you need to manually trigger highlighting:
// Call after dynamically inserting Rich Text HTML
if (typeof Prism !== "undefined") {
Prism.highlightAll();
}
5. Table cells contain unwrapped <p> tags causing extra spacing.
Contentful wraps cell content in paragraph nodes. If you want tighter table cells, strip the outer paragraph in your table cell renderer:
[BLOCKS.TABLE_CELL]: function (node, next) {
var inner = next(node.content);
// Remove wrapping <p> tags from single-paragraph cells
inner = inner.replace(/^<p>([\s\S]*)<\/p>$/, "$1");
return '<td>' + inner + '</td>';
}
Best Practices
1. Always sanitize rendered HTML. Even though you control the renderers, Rich Text content is authored by humans. If you support hyperlinks, someone will eventually paste a javascript: URL. Use sanitize-html as a final pass before sending HTML to the template.
2. Handle unresolved references gracefully. Do not let a missing embedded entry crash your renderer. Return an empty string or a placeholder, and log the issue so you can fix it.
3. Use the Contentful Images API for every embedded image. Never serve raw uploaded images. At minimum, set a max width and convert to WebP. The bandwidth savings are substantial.
4. Keep render options in a separate module. As your content model grows, your render options will grow with it. Extract them into a richTextRenderer.js module instead of inlining them in your route handler.
5. Version your renderers alongside your content model. When you add a new embeddable content type in Contentful, add a corresponding renderer immediately. If you forget, that content type will silently render as nothing.
6. Test with real content, not fixtures. Rich Text JSON is complex and hand-writing test fixtures is error-prone. Fetch actual entries from your Contentful space in your tests, or export entries using the Contentful CLI and use those as fixtures.
7. Set explicit include depth per query. Do not rely on the default. Be intentional about how deep you resolve linked content. Over-fetching wastes bandwidth; under-fetching produces broken renders.
8. Log rendering errors in production. Wrap your renderRichText function in a try-catch and log failures. A broken renderer should not crash your server — it should serve a degraded version of the page and alert you.