Rendering nested block content
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:
- Call
blocksToHtml()recursively withprops.node.textas the blocks - Pass through your serializers and options so nested custom blocks are handled properly
- 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.
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.