BlogPost
by: Jonathan Folland February 6, 2025

Rendering Rich Text JSON in Astro.js with Umbraco Headless Delivery API

When working with Astro.js and Umbraco's Headless Delivery API, one challenge developers may face is dynamically rendering rich text content stored as JSON. Unlike traditional HTML content that can be injected using set:html, structured JSON data requires a more flexible approach—especially when you need to integrate custom components for handling specific elements.

The Challenge

Astro’s set:html directive is a common way to render raw HTML content safely. However, when dealing with structured JSON—such as Umbraco’s umb-rte-element—this method becomes limiting. We need a way to parse the JSON structure and dynamically render the appropriate components while maintaining Astro's component-based architecture.

The Solution

To handle this, we can create a recursive Astro component that processes the JSON and renders the appropriate elements using Astro components. The types in the following code were generated using the Umbraco.Community.DeliveryApiExtensions package, which helps define strong types for the JSON response. By using strong typing and community extensions like Umbraco.Community.DeliveryApiExtensions, we ensure safer and more predictable handling of JSON-based content.

Sample Json:

"blogRichText": {
            "tag": "#root",
            "attributes": {},
            "elements": [
                {
                    "tag": "p",
                    "attributes": {
                        "id": "someId",
                        "class": "some-class"
                    },
                    "elements": [
                        {
                            "text": "This is ",
                            "tag": "#text"
                        },
                        {
                            "tag": "strong",
                            "attributes": {},
                            "elements": [
                                {
                                    "text": "bold text",
                                    "tag": "#text"
                                }
                            ]
                        },
                        {
                            "text": " with an object following",
                            "tag": "#text"
                        }
                    ]
                },
                {
                    "tag": "umb-rte-block",
                    "attributes": {
                        "content-id": "705a6633-3e20-4f3a-be76-4b450a397608"
                    },
                    "elements": []
                },
                {
                    "tag": "p",
                    "attributes": {},
                    "elements": [
                        {
                            "text": "with some more text",
                            "tag": "#text"
                        }
                    ]
                }
            ],
            "blocks": [
                {
                    "content": {
                        "contentType": "angularCounter",
                        "id": "705a6633-3e20-4f3a-be76-4b450a397608",
                        "properties": {}
                    },
                    "settings": null
                }
            ]
        }

RichTextField.astro

---
import type { RichTextGenericElementModel , RichTextRootElementModel , RichTextTextElementModel  } from"@/api/umbraco";
import {richTextFieldHasContent} from"./RichTextHelper"import RenderRichText from'./RenderRichText.astro';

interface Props {
  richTextField: RichTextGenericElementModel | RichTextRootElementModel | RichTextTextElementModel | null | undefined;
}

const { richTextField } = Astro.props;

if (!richTextFieldHasContent(richTextField)) returnnull;
// If the field is the root element, grab the blocks for use in block elements.let blocks: any[] = [];
if (richTextField && richTextField.tag === '#root') {
  const root = richTextField as RichTextRootElementModel;
  blocks = root.blocks;
}
---
<divclass="rich-text-block container mx-auto px-[15px]"><RenderRichTextnode={richTextField}blocks={blocks}></div>

RenderRichText.astro:

---
// filepath: /src/components/richText/RenderRichText.astro
import RichTextFieldBlockItem from './RichTextFieldBlockItem.astro';
import type { ApiBlockItemModel, RichTextGenericElementModel, RichTextRootElementModel, RichTextTextElementModel } from "@/api/umbraco";
import RenderRichTextComponent from './RenderRichText.astro';

interface Props {
  node: RichTextGenericElementModel | RichTextRootElementModel | RichTextTextElementModel | null | undefined;
  blocks: ApiBlockItemModel[] | null | undefined;
}

const { node, blocks } = Astro.props;

if (!node) return null;

const voidElements = [
  "area",
  "base",
  "br",
  "col",
  "embed",
  "hr",
  "img",
  "input",
  "link",
  "meta",
  "param",
  "source",
  "track",
  "wbr"
];

const isText = node.tag === '#text';
const textNode = isText ? nodeas RichTextTextElementModel : null;
const isRoot = node.tag === '#root';
const rootNode = isRoot ? nodeas RichTextRootElementModel : null;
const isBlock = node.tag === 'umb-rte-block';
const blockNode = isBlock ? nodeas RichTextGenericElementModel : null;
const block = isBlock ? blocks?.find((b) => b.content && b.content.id === blockNode?.attributes['content-id']) : null;

const isGeneric = !isText && !isRoot && !isBlock;
const genericNode = isGeneric ? nodeas RichTextGenericElementModel : null;
const GenericTag = genericNode?.tag|| 'div';
const isVoidElement = voidElements.includes(GenericTag);
---

{isText && <Fragment set:html={textNode?.text}></Fragment>}

{isRoot && rootNode?.elements.map((child, i) => 
  <RenderRichTextComponent node={child} blocks={blocks} />)}

{isBlock && <RichTextFieldBlockItem block={block} />}

{isGeneric && isVoidElement && (
  <GenericTag {...genericNode?.attributes} />
)}

{isGeneric && !isVoidElement && (
  <GenericTag {...genericNode?.attributes}>
    {genericNode?.elements.map((child, i) => 
    )}
  </GenericTag>
)}

Breaking Down the Code

  • RichTextField.astro receives the top level json block for the rich text field, retrieves blocks if present and passes to the recursive RenderRichText astro component.
  • The RenderRichText astro component recursively processes the JSON structure.
  • For #text tag elements, the process simply writes the text. We use set:html on a Fragment element to avoid duplicate escaping code text blocks.
  • For void elements (hr, br, etc), the process writes the element along with any attributes and a closing tag.
  • For non-void generic HTML elements (div, p, ol, etc.), the process writes the element along with any attributes and then recursively calls itself to render any child elements.
  • Finally, for umb-rte-element tags, it passes the associated block data to a RichTextFieldBlockItem whic determines the astro component that should render the block.

Why This Matters

This approach gives Astro.js developers working with Umbraco's Headless Delivery API a structured way to render rich text without relying on set:html and allows for easily consuming regular astro components for rendering .

Conclusion

If you're integrating Umbraco's Headless Delivery API with Astro.js, handling rich text JSON properly is key to a smooth developer experience. With this method, you can maintain a clean component-based structure while rendering dynamic content efficiently.

Share


Harness the power of headless to achieve marketing excellence!

The team at Given Data LLC continuously monitors advances in the content management space, keeping us ahead of the competition. Urgent need? call us

+1 786-475-5504

Contact Us Arrow Right 2

Services

Resources

Opportunities

Newsletter

©2025 Given Data, LLC. All rights reserved.