Rendering linked assets and entries in the Contentful Rich Text field

In order to understand how to render linked assets and entries inside the Contentful Rich Text field on the front end, it is crucial to understand how linked assets and entries work across Contentful as a whole. This post focuses on working with Contentful in a JavaScript ecosystem, but the principals and methods are the same across the tech stack and can be applied to your favorite programming language, too.

Before you get started, you might want to check out this blog post to take a deep dive into the inner workings of the Contentful REST API and GraphQL API, how our links are returned in the response and how they can be resolved into a rich object graph.

Exploring the Rich Text field editor

Now that we’re familiar with how Contentful returns items and their linked entries and assets, and how we can resolve the links either manually (through a lot of hard work!) or with the JavaScript SDK (that’s nice and easy), let’s look at how it works with links inside the Rich Text field.

Rich Text is a field type that enables authors to create rich text content, similar to traditional "what you see is what you get" (WYSIWYG) editors. The key difference here is that the Contentful Rich Text field response is returned as pure JSON rather than HTML. Additionally, it allows entries and assets within our Contentful space to be linked dynamically and embedded within the flow of the text. It offers common text formatting options such as paragraphs, lists and all that good stuff, but allows us to embed and link other references, too.

Read more about the Rich Text field here

Find an example of the Rich Text field editor in the Contentful UI below. It includes several paragraphs but also links a blog post, a video embed entry, an image asset and a code block entry.

Screenshot of rich text field embedded entries

If you want to code along with the post, you can create the following content types in your Contentful space, which we will use in our examples:

The code block entry contains the following fields:

  • Description (short text)

  • Language (short text)

  • Code (long text displayed as a markdown field)

The video embed entry contains the following fields:

  • Title (short text)

  • Embed URL (short text)

That’s the visual structure of the Rich Text field, but how is the content — and especially the references — represented in the JSON response? What are the differences between the REST and GraphQL API responses? Let’s take a look.

Rendering Rich Text references using the REST API

The following examples use JavaScript to fetch data from this example blog post. The blog post is served on an application built with Next.js — but we won’t be going into Next.js in this post.

We can request the data via this URL:

https://cdn.contentful.com/spaces/{{spaceId}}/environments/master/entries?access_token={{accessToken}}&content_type=blogPost&fields.slug=the-power-of-the-contentful-rich-text-field&include=10

It returns this raw response from the REST API. This is trimmed down to show only the fields we are concerned with in this example:

{
  "items": [
    {
      "fields": {
        "title": "The power of the Contentful Rich Text field",
        "slug": "the-power-of-the-contentful-rich-text-field",
        "body": {
          "content": [
            {
               "nodeType": "text",
                "value": "Here is an inline entry that links to another blog post: ",
                "marks": [],
                "data": {}
             },
             {
              "nodeType": "embedded-entry-inline",
              "content": [],
              "data": {  
                "target": {
                  "sys": {
                    "id": "999888",
                    "type": "Link",
                    "linkType": "Entry"
                  }
                }
              }
            },
            {
              "content": [
                {
                  "value": "Here is a video entry embedded as an entry in the Rich Text field editor.",
                  "nodeType": "text"
                }
              ],
              "nodeType": "paragraph"
            },
            {
              "data": {
                "target": {
                  "sys": {
                    "id": "12345",
                    "type": "Link",
                    "linkType": "Entry"
                  }
                }
              },
              "content": [],
              "nodeType": "embedded-entry-block"
            },
            {
              "content": [
                {
                  "value": "Here is an image asset.",
                  "nodeType": "text"
                }
              ],
              "nodeType": "paragraph"
            },
            {
              "data": {
                "target": {
                  "sys": {
                    "id": "67890",
                    "type": "Link",
                    "linkType": "Asset"
                  }
                }
              },
              "content": [],
              "nodeType": "embedded-asset-block"
            },
            {
              "content": [
                {
                  "value": "And here is a code block entry.",
                  "nodeType": "text"
                }
              ],
              "nodeType": "paragraph"
            },
            {
              "data": {
                "target": {
                  "sys": {
                    "id": "99999",
                    "type": "Link",
                    "linkType": "Entry"
                  }
                }
              },
              "content": [],
              "nodeType": "embedded-entry-block"
            }
          ]
        }
      }
    }
  ],
  "includes": {
    "Entry": [
      {
       "id": "999888",
        "type": "Entry",
        "contentType": {
          "sys": {
            "type": "Link",
            "linkType": "ContentType",
            "id": "blogPost"
          }
        },
        "fields": {
          "title": "This blog comes complete with an RSS feed that's generated at build time",
          "slug": "this-blog-comes-complete-with-an-rss-feed-thats-generated-at-build-time",
          // More blog post fields...
        },
      },
      {
        "sys": {
          "id": "12345",
          "type": "Entry",
          "contentType": {
            "sys": {
              "type": "Link",
              "linkType": "ContentType",
              "id": "videoEmbed"
            }
          }
        },
        "fields": {
          "title": "Example video embed",
          "embedUrl": "https://www.youtube.com/embed/97Hg0OYFC0w"
        }
      },
      {
        "sys": {
          "id": "99999",
          "type": "Entry",
          "contentType": {
            "sys": {
              "type": "Link",
              "linkType": "ContentType",
              "id": "codeBlock"
            }
          }
        },
        "fields": {
          "description": "Example code block",
          "language": "javascript",
          "code": "export function formatPublishedDateForDisplay(dateString) {\n  const timestamp = Date.parse(dateString);\n  const date = new Date(timestamp);\n  return `${date.getDate()} ${getMonthStringFromInt(\n    date.getMonth(),\n  )} ${date.getFullYear()}`;\n}"
        }
      }
    ],
    "Asset": [
      {
        "sys": {
          "id": "67890",
          "type": "Asset"
        },
        "fields": {
          "title": "colourful-galaxy",
          "description": "Blue and purple galaxy digital wallpaper",
          "file": {
            "url": "//images.ctfassets.net/.../example.jpg",
            "fileName": "example.jpg"
          }
        }
      }
    ]
  }
}

We can see that the entry response contains two top-level nodes: items and includes.

Inspecting the Rich Text body field, we observe that:

  • items[0].fields.body.content contains a number of nodes — text nodes (with nodeType: "paragraph") and additional nodes with the property data.target.type: "Link" and nodetype: "embedded-entry-block", nodetype: "embedded-entry-inline", and nodeType: "embedded-asset-block"with empty content nodes

Wait — the linked entries inside items[0].fields.body.content are empty! Where is our data?

The actual data for the linked entries referenced in the body.content field are in the includes object, returned alongside the top-level items array:

  • includes.Entry contains the data for the two linked entries (the code block and the video embed)

  • includes.Asset includes the data for the linked asset (the image)

What do we do now? How do we link all the data together so we can access it inside of the body node as we would expect?

Using the Contentful JavaScript SDK

The good news is, that if we’re using a Contentful SDK to make a call to the Contentful API, those linked assets and entries in the Rich Text field will be resolved for you.

As discussed in this blog post, under the hood, the JavaScript SDK uses the contentful-resolve-response package, which converts the flat nodes into a rich tree of data. The one limitation of the Contentful API to remember is that it will only return linked entries up to a maximum of 10 levels deep that can be resolved. However, given that our Rich Text field contains embedded entries and assets only one level deep in this example, we’re good to go.

The linked entries that are returned from the API are determined by the include parameter on the request to the API. Read more about the include parameter here.

Make the same call to fetch an entry including a Rich Text field via the JavaScript SDK:

const post = await client
  .getEntries({
    content_type: "blogPost",
    limit: 1,
    include: 10,
    "fields.slug": "the-power-of-the-contentful-rich-text-field",
  })
  .then((entry) => console.log(entry))
  .catch(console.error);

And here’s the processed JavaScript object returned from the API call via the SDK, containing the data we need for each node in the Rich Text response.:

{
  "items": [
    {
      "fields": {
        "title": "The power of the Contentful Rich Text field",
        "slug": "the-power-of-the-contentful-rich-text-field",
        "body": {
          "content": [
            {
              "content": [
                  {
              "nodeType": "text",
              "value": "Here is an inline entry that links to another blog post: ",
            },
              ],
              "nodeType": "paragraph",
            },
            {
              "content": [],
              "data": {
                "target": {
                  "sys": {
                    "id": "999888",
                    "type": "Entry",
                    "contentType": {
                      "sys": {
                        "type": "Link",
                        "linkType": "ContentType",
                        "id": "blogPost"
                      }
                    },
                  },
                  "fields": {
                    "title": "This blog comes complete with an RSS feed that's generated at build time",
                    "slug": "this-blog-comes-complete-with-an-rss-feed-thats-generated-at-build-time",
                    // More blog post fields
                  },
                },
              },
              "nodeType": "embedded-entry-inline",
            },
            {
              "content": [
                {
                  "value": "Here is a video entry embedded as an entry in the Rich Text field editor.",
                  "nodeType": "text"
                }
              ],
              "nodeType": "paragraph"
            },
            {
              "data": {
                "target": {
                  "sys": {
                    "id": "12345",
                    "type": "Entry",
                    "contentType": {
                      "sys": {
                        "type": "Link",
                        "linkType": "ContentType",
                        "id": "videoEmbed"
                      }
                    }
                  },
                  "fields": {
                    "title": "Example video embed",
                    "embedUrl": "https://www.youtube.com/embed/97Hg0OYFC0w"
                  }
                }
              },
              "content": [],
              "nodeType": "embedded-entry-block"
            },
            {
              "content": [
                {
                  "value": "Here is an image asset.",
                  "nodeType": "text"
                }
              ],
              "nodeType": "paragraph"
            },
            {
              "data": {
                "target": {
                  "sys": {
                    "id": "67890",
                    "type": "Asset"
                  },
                  "fields": {
                    "title": "colourful-galaxy",
                    "description": "Blue and purple galaxy digital wallpaper",
                    "file": {
                      "url": "//images.ctfassets.net/.../example.jpg",
                      "fileName": "example.jpg"
                    }
                  }
                }
              },
              "content": [],
              "nodeType": "embedded-asset-block"
            },
            {
              "content": [
                {
                  "value": "And here is a code block entry.",
                  "nodeType": "text"
                }
              ],
              "nodeType": "paragraph"
            },
            {
              "data": {
                "target": {
                  "sys": {
                    "id": "99999",
                    "type": "Entry",
                    "contentType": {
                      "sys": {
                        "type": "Link",
                        "linkType": "ContentType",
                        "id": "codeBlock"
                      }
                    }
                  },
                  "fields": {
                    "description": "Example code block",
                    "language": "javascript",
                    "code": "export function formatPublishedDateForDisplay(dateString) {\n  const timestamp = Date.parse(dateString);\n  const date = new Date(timestamp);\n  return `${date.getDate()} ${getMonthStringFromInt(\n    date.getMonth(),\n  )} ${date.getFullYear()}`;\n}"
                  }
                }
              },
              "content": [],
              "nodeType": "embedded-entry-block"
            }
          ],
          "nodeType": "document"
        }
      }
    }
  ]
}

Notice how all the data that was previously contained in a separate includes node from the raw REST API response is now inserted beautifully into the Rich Text field response — where we would expect.

Now that we have our links and their data inside the Rich Text field where we need it in a nicely packaged JavaScript object, how do we render the HTML for each node?

Rendering the Rich Text response from REST with linked assets and entries on the front end

Contentful provides you with tools to speed up your workflow on the front end and to allow you to work with the Rich Text field data and render the nodes into HTML — Rich Text field renderers. For this example, we are going to be using the @contentful/rich-text-react-renderer to demonstrate the concepts in JavaScript and React.

There are a number of Rich Text field renderer packages available for your favorite programming languages and frameworks — check them out on GitHub here.

Let’s return to the example Rich Text field with two embedded links — a code block entry and a video embed entry — and an image asset. Most likely, we will want to display the data from these entries in particular ways for the front end, such as by using specific HTML elements, adding CSS classes, or rendering custom React components.

With the response from the REST API processed by the JavaScript SDK — which has linked the entries and assets for us — we can call documentToReactComponents with an optional options parameter, allowing us control over how our data is displayed on the page.

Notice below, that for each node in the Rich Text response, the SDK has resolved the links for us. We can access the type of entry or asset using node.data.target.contentType.sys.id, and access the fields using node.data.target.fields and so on. 

This is where the link resolution magic of the SDK comes into play.

import { documentToReactComponents } from "@contentful/rich-text-react-renderer";
import { BLOCKS, INLINES } from "@contentful/rich-text-types";

// Create a bespoke renderOptions object to target BLOCKS.EMBEDDED_ENTRY (linked block entries e.g. code blocks)
// INLINES.EMBEDDED_ENTRY (linked inline entries e.g. a reference to another blog post)
// and BLOCKS.EMBEDDED_ASSET (linked assets e.g. images)

const renderOptions = {
  renderNode: {
    [INLINES.EMBEDDED_ENTRY]: (node, children) => {
      // target the contentType of the EMBEDDED_ENTRY to display as you need
      if (node.data.target.sys.contentType.sys.id === "blogPost") {
        return (
          <a href={`/blog/${node.data.target.fields.slug}`}>            {node.data.target.fields.title}
          </a>
        );
      }
    },
    [BLOCKS.EMBEDDED_ENTRY]: (node, children) => {
      // target the contentType of the EMBEDDED_ENTRY to display as you need
      if (node.data.target.sys.contentType.sys.id === "codeBlock") {
        return (
          <pre>
            <code>{node.data.target.fields.code}</code>
          </pre>
        );
      }

      if (node.data.target.sys.contentType.sys.id === "videoEmbed") {
        return (
          <iframe
            src={node.data.target.fields.embedUrl}
            height="100%"
            width="100%"
            frameBorder="0"
            scrolling="no"
            title={node.data.target.fields.title}
            allowFullScreen={true}
          />
        );
      }
    },

    [BLOCKS.EMBEDDED_ASSET]: (node, children) => {
      // render the EMBEDDED_ASSET as you need
      return (
        <img
          src={`https://${node.data.target.fields.file.url}`}
          height={node.data.target.fields.file.details.image.height}
          width={node.data.target.fields.file.details.image.width}
          alt={node.data.target.fields.description}
        />
      );
    },
  },
};

export default function BlogPost(props) {
  const { post } = props;

  return (
    <>
       {documentToReactComponents(post.fields.body, renderOptions)}
    </>
  );
}

TL;DR: Don’t worry about links if you’re using an SDK and the contentful-rich-text-react-renderer! For each node in the Rich Text response, access the type of entry or asset using data.target.contentType.sys.id, and access the fields using data.target.fields and so on.

The SDK and the renderer package handles linked entries and assets beautifully for us. But how does it work when using the GraphQL API?

Rendering Rich Text references using the GraphQL API

The Contentful GraphQL API doesn’t require an SDK to handle linked entries. Understanding the concepts of links covered in this blog post helps us out massively. 

To explore the GraphQL query in this example, navigate to the following URL and paste the query below into the explorer (without the const and =):

https://graphql.contentful.com/content/v1/spaces/84zl5qdw0ore/explore?access_token=_9I7fuuLbV9FUV1p596lpDGkfLs9icTP2DZA5KUbFjA

The Rich Text field response from the GraphQL API is different and contains two top-level nodes.

Here’s the GraphQL query for our blog post:

const query = `{
    blogPostCollection(limit: 1, where: {slug: "the-power-of-the-contentful-rich-text-field"}) {
      items {
        sys {
          id
        }
        # For this example, we’ll focus on the Rich Text field query below
        # and omit the rest of the blog post fields
        body {
          json
          links {
            entries {
              inline {
                sys {
                  id
                }
                __typename
                ... on BlogPost {
                  title
                  slug
                }
              }
              block {
                sys {
                  id
                }
                __typename
                ... on CodeBlock {
                  description
                  language
                  code
                }
                ... on VideoEmbed {
                  embedUrl
                  title
                }
              }
            }
            assets {
              block {
                sys {
                  id
                }
                url
                title
                width
                height
                description
              }
            }
          }
        }
      }
    }
  }`;

And here’s how we can query the Contentful GraphQL API using fetch:

const fetchOptions = {
  method: "POST",
  headers: {
    Authorization: "Bearer " + {ACCESS_TOKEN},
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ query }),
};

const response = await fetch(`https://graphql.contentful.com/content/v1/spaces/{SPACE_ID}`, fetchOptions).then((response) => console.log(response.json()));

The Rich Text field response (blogPost.body) contains the following two top-level nodes — json and links. json includes the Rich Text JSON tree representing whatever people put into the editor. It is to point out that this JSON structure only includes ids to possibly linked references. These references can then be queried using the links node.

"body": {
  # JSON structure of the Rich Text field
  "json": {
    # ...
  }
  # all referenced assets/entries
  "links": {
    # ...
  }
}

The references are not automatically resolved inside of the Rich Text JSON. This means we have to take a different approach to render and resolve links when using GraphQL.

Rendering the Rich Text response from GraphQL with linked assets and entries on the front end

We can still use documentToReactComponents to render our Rich Text field data to the DOM, but instead of passing in an options object, we’ll need to construct the object using a custom function to process a bit of logic to resolve our links.

In order to target asset and entry data when rendering BLOCKS.EMBEDDED_ENTRY and BLOCKS.EMBEDDED_ASSET with documentToReactComponents, we can create an assetMap (id: asset) and entryMap (id: entry) to store data we can reference by ID.

When the renderOptions reaches the entry and asset types, we can access the data from the maps we created at the top of the function, and render it accordingly. 

import { documentToReactComponents } from "@contentful/rich-text-react-renderer";
import { BLOCKS, INLINES } from "@contentful/rich-text-types";

// Create a bespoke renderOptions object to target BLOCKS.EMBEDDED_ENTRY (linked block entries e.g. code blocks)
// INLINES.EMBEDDED_ENTRY (linked inline entries e.g. a reference to another blog post)
// and BLOCKS.EMBEDDED_ASSET (linked assets e.g. images)

function renderOptions(links) {
  // create an asset map
  const assetMap = new Map();
  // loop through the assets and add them to the map
  for (const asset of links.assets.block) {
    assetMap.set(asset.sys.id, asset);
  }

  // create an entry map
  const entryMap = new Map();
  // loop through the block linked entries and add them to the map
  for (const entry of links.entries.block) {
    entryMap.set(entry.sys.id, entry);
  }

   // loop through the inline linked entries and add them to the map
  for (const entry of links.entries.inline) {
    entryMap.set(entry.sys.id, entry);
  }

  return {
    // other options...

    renderNode: {
      // other options...
       [INLINES.EMBEDDED_ENTRY]: (node, children) => {
        // find the entry in the entryMap by ID
        const entry = entryMap.get(node.data.target.sys.id);

        // render the entries as needed
        if (entry.__typename === "BlogPost") {
          return <a href={`/blog/${entry.slug}`}>{entry.title}</a>;
        }
      },
      [BLOCKS.EMBEDDED_ENTRY]: (node, children) => {
        // find the entry in the entryMap by ID
        const entry = entryBlockMap.get(node.data.target.sys.id);

        // render the entries as needed by looking at the __typename 
        // referenced in the GraphQL query
        if (entry.__typename === "CodeBlock") {
          return (
            <pre>
              <code>{entry.code}</code>
            </pre>
          );
        }

       if (entry.__typename === "VideoEmbed") {
         return (
            <iframe
              src={entry.embedUrl}
              height="100%"
              width="100%"
              frameBorder="0"
              scrolling="no"
              title={entry.title}
              allowFullScreen={true}
            />
          );
        }

      },
      [BLOCKS.EMBEDDED_ASSET]: (node, next) => {
        // find the asset in the assetMap by ID
        const asset = assetMap.get(node.data.target.sys.id);

        // render the asset accordingly
        return (
          <img src={asset.url} alt="My image alt text" />
        );
      },
    },
  };
}

// Render post.body.json to the DOM using
// documentToReactComponents from "@contentful/rich-text-react-renderer"

export default function BlogPost(props) {
  const { post } = props;

  return <>{documentToReactComponents(post.body.json, renderOptions(post.body.links))}</>;
}

And there we have it! It’s a little more work to render our links with GraphQL, but if we understand how the SDK works, its magic and how links work across Contentful as a whole, we’re all set.

Take a look at this example repository on GitHub, which is a demo Next.js application that contains all of the example code in this post, and demonstrates how we can fetch this data and render the Rich Text field linked assets and entries using both the REST API with JavaScript SDK and the GraphQL API. 

To wrap up

Using the JavaScript SDK with the REST API and the contentful-rich-text-react-renderer we can define our renderOptions without worrying about having to resolve our links. All the data we need is available via node.data.target.

Using the GraphQL API and the contentful-rich-text-react-renderer, we have to perform the mapping of the linked entries ourselves, which we can do when defining our renderOptions and passing in the links as an additional parameter.

The power of the Contentful Rich Text field is that it is stored in pure JSON data. With the knowledge of how linked assets and entries are referenced at a content type level, you’re empowered to render the contents of your Contentful Rich Text fields, with or without SDKs or other supporting packages. Go forth and build stuff!

If you’ve got any questions about linked assets and entries in Contentful, come and join the Contentful Community Slack, where we’ll be more than happy to help!

And remember, build stuff, learn things and love what you do.  

About the author

Don't miss the latest

Get updates in your inbox
A monthly newsletter to help you build better digital experiences with Contentful.
add-circle arrow-right remove style-two-pin-marker subtract-circle