erojas.devertek.io logo
Rich Text Rendering System in Nextjs and Contentful

Dissecting rich text rendering for Contentful

In this post, we'll walk through a real-world implementation that turns Contentful JSON into:

  • Paragraphs

  • Syntax highlighted code blocks

  • Responsive tables

  • Inline & block embedded assets

  • Image optimization via next-image

The following section makes it clear that we’re not rendering raw HTML—instead, we are mapping Contentful Rich Text nodes into React components. The RichTextHelpers utility handles smart logic, such as detecting when a paragraph actually contains code, while the CodeHighlighter component provides a reusable way to render that code with proper formatting. The LinkedAsset type ensures that embedded assets like images or files are handled safely and consistently, with full TypeScript support. Altogether, this establishes a solid foundation for maintainability and extensibility in how content is rendered.

1import Image from "next/image";
2import { BLOCKS, type Block, type Inline } from "@contentful/rich-text-types";
3import { type Options, type NodeRenderer } from "@contentful/rich-text-react-renderer";
4import { RichTextHelpers } from "./rich-text-helpers";
5import CodeHighlighter from "../components/CodeHighlighter";
6import { LinkedAsset } from "../types/Blog";
7

Paragraph Renderer

This approach is clever because it acknowledges that not all paragraphs are created equal—some contain actual code rather than plain text. Instead of forcing Contentful editors to manually tag or classify these blocks, which introduces room for human error and inconsistency, the system automatically detects when a paragraph includes code content. This ensures that the content creator can write naturally while the rendering engine intelligently interprets the intent behind the text. The result is a writing experience that feels seamless and human-friendly, while still delivering a polished, developer-optimized output on the front end. In other words, the renderer isn’t just translating structure—it’s understanding purpose.

1const paragraphRenderer: NodeRenderer = (node, children) => {
2  const { hasCode, codeContent } = RichTextHelpers.hasCodeBlock(node as Block | Inline);
3  return hasCode ? <CodeHighlighter code={codeContent} /> : <p>{children}</p>;
4};
5

Styled Table Rendering

Tables fetched directly from Contentful are delivered in their most basic structural form—completely unstyled and often visually jarring. Without any presentation layer, they can appear misaligned, overflow on mobile screens, or become outright unreadable. By implementing a custom renderer, we transform these raw tables into responsive, visually polished elements using Tailwind CSS. This approach ensures they adapt gracefully to different screen sizes, add a professional look without overwhelming design, and maintain proper HTML semantics for accessibility and SEO. In short, this renderer doesn’t just make tables look better—it makes them usable, inclusive, and production-ready.

1const tableRenderer: NodeRenderer = (_node, children) => (
2  <div className="overflow-x-auto my-6">
3    <table className="min-w-full border border-gray-300 rounded-lg">
4      <tbody>{children}</tbody>
5    </table>
6  </div>
7);
8

Supporting rows and cells

1const tableRowRenderer: NodeRenderer = (_node, children) => (
2  <tr className="border-b border-gray-200">{children}</tr>
3);
4const thRenderer: NodeRenderer = (_node, children) => (
5  <th className="px-4 py-3 bg-gray-50 text-left text-sm font-semibold text-gray-900 border-r border-gray-200 last:border-r-0">
6    {children}
7  </th>
8);
9const tdRenderer: NodeRenderer = (_node, children) => (
10  <td className="px-4 py-3 text-sm text-gray-700 border-r border-gray-200 last:border-r-0">
11    {children}
12  </td>
13);
14

How We Efficiently Resolve Embedded Assets

When Contentful delivers rich text content, it separates embedded assets—such as images, PDFs, or videos—from the main JSON tree. That means the content references these assets by ID, but doesn’t include the actual asset data inline. To efficiently resolve these references during rendering, we build a Map that allows constant-time (O(1)) lookups of assets by their IDs. This makes the rendering process extremely fast and scalable, even as the number of embedded assets grows. Additionally, we proactively filter out invalid or empty IDs, which helps prevent runtime errors and ensures that only valid assets are processed. This strategy enhances both performance and reliability during rendering.

1const embeddedAssetRenderer: NodeRenderer = (node) => {
2  const id = node.data?.target?.sys?.id as string | undefined;
3  if (!id || id.trim() === '') {
4    console.warn('Embedded asset with empty or missing ID:', node.data);
5    return null;
6  }
7  const asset = assetMap.get(id);
8  if (!asset) {
9    console.warn(`Embedded asset not found in links: ${id}`);
10    return null;
11  }
12  const isImage = asset.contentType?.startsWith("image/");
13  const alt = asset.description || asset.title || "";
14  if (isImage) {
15    return (
16      <figure className="my-6 w-full md:w-1/2 lg:w-1/3 md:inline-block md:px-2">
17        <Image
18          src={asset.url}
19          alt={alt}
20          width={asset.width ?? 1200}
21          height={asset.height ?? 800}
22          className="w-full h-auto rounded-lg"
23          sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw"
24        />
25      </figure>
26    );
27  }
28  return (
29    <p className="my-4">
30      <a href={asset.url} target="_blank" rel="noreferrer" className="underline">
31        {asset.title || asset.url}
32      </a>
33    </p>
34  );
35};
36

Bringing It All Together: The Render Pipeline

This final configuration is where all the custom renderers we defined—paragraphs, tables, table rows, cells, and embedded assets—are registered and connected to Contentful’s rendering engine. By returning an object that maps specific Contentful node types (like BLOCKS.PARAGRAPH or BLOCKS.EMBEDDED_ASSET) to their corresponding React components, we effectively teach our application how to interpret and visually render each part of the rich text document. This setup acts as the rendering blueprint, transforming raw content into styled, interactive, and optimized UI components. It not only centralizes control over how each content block is displayed, but also makes it easy to scale in the future—new renderers can be added or existing ones modified without changing the underlying Contentful content.

1return {
2  renderNode: {
3    [BLOCKS.PARAGRAPH]: paragraphRenderer,
4    [BLOCKS.TABLE]: tableRenderer,
5    [BLOCKS.TABLE_ROW]: tableRowRenderer,
6    [BLOCKS.TABLE_HEADER_CELL]: thRenderer,
7    [BLOCKS.TABLE_CELL]: tdRenderer,
8    [BLOCKS.EMBEDDED_ASSET]: embeddedAssetRenderer,
9  },
10};
11

Final Takeaways 🎯

This system transforms Contentful from a basic content source into a developer-friendly publishing engine. By mapping rich text nodes to React components, we don’t just render content—we enhance it with automatic code highlighting, responsive tables, optimized asset rendering, and strong TypeScript support. Editors can create freely while developers retain full control over presentation, resulting in a workflow that is both scalable and production-ready. Until next time keep coding!.