Rendering nested block content

2 replies
Last updated: Jan 18, 2021
I’m struggling to get nested block content rendering. I have a custom block that itself contains block content.

function ImageBlockLeftSerializer(props) {                                                            
  const img = h('img', {src: getImageUrl({ node: props.node.image, options: props.options})})

  return h('div', {className: 'row'}, [
    h('div', {className: 'col-12 col-md-6 d-flex align-items-center'}, img),
    h('div', {className: 'col-12 col-md-6'},  props.node.text)
  ])
}
for a schema like this:

export default {
  title: 'Block Content',
  name: 'blockContent',
  type: 'array',
  of: [
    // normal stuff
    {
      type: 'imageBlockLeft'
    }
  ]
if I put
props.node.text
through
blockContentToHtml
in my serializer then I get the HTML output as a string but not as HTML. That’s the closest I’ve got. Any ideas?
Custom block definition:

export default {
  name: 'imageBlockLeft',
  title: 'Image Block (Left)',
  type: 'object',
  fields: [
    {
      name: 'image',
      title: 'Image',
      type: 'image',
      validation: Rule => Rule.required()
    },
    {
      name: 'text',
      title: 'Text',
      type: 'blockContent'
    }
  ]
}
Example props:


{
  "node": {
    "_key": "8aadac420e1a",
    "_type": "imageBlockLeft",
    "image": {
      "_type": "image",
      "asset": {
        "_ref": "image-35774cbd8c18717d79503e716e5829dad730a134-407x229-png",
        "_type": "reference"
      }
    },
    "text": [
      {
        "_key": "034bfcfdf0dd",
        "_type": "block",
        "children": [
          {
            "_key": "7cb2dc325caa",
            "_type": "span",
            "marks": [],
            "text": "Test ImageBlockLeft"
          }
        ],
        "markDefs": [],
        "style": "h1"
      },
      {
        "_key": "2739c417f299",
        "_type": "block",
        "children": [
          {
            "_key": "35c1a24c980b",
            "_type": "span",
            "marks": [],
            "text": "Some dummy content in the text attribute of this content type."
          }
        ],
        "markDefs": [],
        "style": "normal"
      }
    ]
  },
  "options": {
    "projectId": "89ig8fee",
    "dataset": "ccblog",
    "imageOptions": {}
  },
  "children": []
}
AI Update

Looking at your code, the issue is that you're trying to use hyperscript to handle HTML strings, but in a server-side rendering context with @sanity/block-content-to-html, you need to either return raw HTML strings or properly handle the recursive serialization.

The problem is that when you call blockContentToHtml() on props.node.text, it returns an HTML string, but hyperscript treats strings as text content (escaping them) rather than parsing them as HTML. Hyperscript in server-side contexts doesn't have an innerHTML property like browser DOM elements do.

Here's the correct approach - return raw HTML strings from your serializer instead of mixing hyperscript with HTML strings:

const blocksToHtml = require('@sanity/block-content-to-html')

function ImageBlockLeftSerializer(props) {
  const imageUrl = getImageUrl({ node: props.node.image, options: props.options })
  
  // Recursively render the nested block content
  const nestedHtml = blocksToHtml({
    blocks: props.node.text,
    serializers: props.serializers, // Pass through serializers for recursive handling
    projectId: props.options.projectId,
    dataset: props.options.dataset,
    imageOptions: props.options.imageOptions
  })
  
  // Return raw HTML string instead of hyperscript
  return `
    <div class="row">
      <div class="col-12 col-md-6 d-flex align-items-center">
        <img src="${imageUrl}" />
      </div>
      <div class="col-12 col-md-6">
        ${nestedHtml}
      </div>
    </div>
  `
}

The @sanity/block-content-to-html library can handle serializers that return either hyperscript objects or HTML strings, so this will work correctly. The key points:

  1. Call blocksToHtml() recursively with props.node.text as the blocks
  2. Pass through your serializers and options so nested custom blocks are handled properly
  3. Return an HTML string that includes the nested HTML output directly

Important migration note: The @sanity/block-content-to-html package is now deprecated. When you have time to migrate, switch to the modern @portabletext/to-html library:

const {toHTML} = require('@portabletext/to-html')

const components = {
  types: {
    imageBlockLeft: ({value}) => {
      const imageUrl = getImageUrl(value.image)
      const nestedHtml = toHTML(value.text, {components}) // Recursive call
      
      return `
        <div class="row">
          <div class="col-12 col-md-6 d-flex align-items-center">
            <img src="${imageUrl}" />
          </div>
          <div class="col-12 col-md-6">
            ${nestedHtml}
          </div>
        </div>
      `
    }
  }
}

const html = toHTML(yourBlockContent, {components})

The main differences: you access the node via value instead of node, use components instead of serializers, and the API is cleaner overall. The recursive pattern remains the same—call the renderer with the nested content and pass through your components configuration.

Solved by using
blocksToHyperScript
rather than
blockContentToHtml
.
The full serializer function:

function ImageBlockLeftSerializer(props) {                                                            
  const img = h('img', {src: getImageUrl({ node: props.node.image, options: props.options})})

  return h('div', {className: 'row'}, [
    h('div', {className: 'col-12 col-md-6 d-flex align-items-center'}, img),
    h('div', {className: 'col-12 col-md-6'},  blocksToHyperScript({ blocks: props.node.text}))
  ])
}

Sanity – Build the way you think, not the way your CMS thinks

Sanity is the developer-first content operating system that gives you complete control. Schema-as-code, GROQ queries, and real-time APIs mean no more workarounds or waiting for deployments. Free to start, scale as you grow.

Was this answer helpful?