How to properly render Portable Text with toHTML in Sanity?

13 replies
Last updated: Oct 14, 2022
Is anyone willing to hold my hand and walk me through setting up the Portable Text? I have looked for as much information as I can on the matter but I can not seem to figure it out.
I have a basic schema set up:

{
      name: "text",
      title: "Text",
      type: 'array',
      of: [ { type: 'block' } ]
    },
And a mess of a .js file to render the content. (Upgrading from block content to html to portable text - therefore I am just trying to replace what we previously had)

import {toHTML} from '@portabletext/to-html'

const BodyText = ({ text, layoutSettings } = {}) => {
  let className = 'body-text';
  let ls = 'col-6';
  if (layoutSettings?.sizing) {
    ls = layoutSettings.sizing;
    className = `${className} ${ls}`;
  }

  if (!text) {
    return `<p class="${className}"></p>`;
  }
  

  if (text.length > 1) {
    return blocks
    .map(block => block.children.map(child => child.text).join(''));
  }

  let bt = toHTML({ blocks: text });
  bt = bt.replace(/<p/, `<p class="${className}" `);
  return bt;
};

export default BodyText;

AI Update

I'd be happy to walk you through setting up Portable Text! Looking at your code, I can see you're on the right track but there are a few issues to fix. Let me break this down step by step.

Your Schema is Perfect

Your schema is exactly right:

{
  name: "text",
  title: "Text",
  type: 'array',
  of: [ { type: 'block' } ]
}

This creates a Portable Text field that stores rich text as structured JSON. Now let's fix the rendering part.

The Main Issues in Your Code

  1. You're passing { blocks: text } to toHTML, but the function expects the array directly as the first argument
  2. You have a reference to blocks variable that doesn't exist (blocks.map(...))
  3. String manipulation with .replace() is fragile - use the components option instead

The Correct Way to Use @portabletext/to-html

The toHTML function takes two arguments:

  1. Your Portable Text array directly (not wrapped in an object)
  2. An options object where you can customize rendering with components

Here's your corrected code:

import { toHTML } from '@portabletext/to-html';

const BodyText = ({ text, layoutSettings } = {}) => {
  let className = 'body-text';
  let ls = 'col-6';
  
  if (layoutSettings?.sizing) {
    ls = layoutSettings.sizing;
    className = `${className} ${ls}`;
  }

  if (!text || text.length === 0) {
    return `<p class="${className}"></p>`;
  }

  // Pass the array directly, then customize with components
  const html = toHTML(text, {
    components: {
      block: {
        // Customize how normal paragraphs render
        normal: ({ children }) => `<p class="${className}">${children}</p>`,
        // Add other block styles if you use them
        h1: ({ children }) => `<h1 class="${className}">${children}</h1>`,
        h2: ({ children }) => `<h2 class="${className}">${children}</h2>`,
      },
    },
  });

  return html;
};

export default BodyText;

Understanding the Components Option

The components option is how you customize your HTML output. It has three main sections:

1. Block Components

For paragraph-level content with different styles:

components: {
  block: {
    normal: ({ children }) => `<p class="${className}">${children}</p>`,
    h1: ({ children }) => `<h1>${children}</h1>`,
    h2: ({ children }) => `<h2>${children}</h2>`,
    blockquote: ({ children }) => `<blockquote>${children}</blockquote>`,
  }
}

2. Mark Components

For inline formatting like bold, italic, and links:

components: {
  marks: {
    strong: ({ children }) => `<strong>${children}</strong>`,
    em: ({ children }) => `<em>${children}</em>`,
    link: ({ children, value }) => `<a href="${value.href}">${children}</a>`,
  }
}

3. Type Components

For custom block types (like images) you might add later:

components: {
  types: {
    image: ({ value }) => {
      return `<img src="${value.asset.url}" alt="${value.alt || ''}" />`;
    },
  }
}

If You're Using React

If this is actually a React component (your code looks like it might be), I'd strongly recommend using @portabletext/react instead - it's much cleaner:

import { PortableText } from '@portabletext/react';

const BodyText = ({ text, layoutSettings }) => {
  const className = layoutSettings?.sizing 
    ? `body-text ${layoutSettings.sizing}` 
    : 'body-text col-6';

  return (
    <PortableText
      value={text}
      components={{
        block: {
          normal: ({ children }) => <p className={className}>{children}</p>,
        },
      }}
    />
  );
};

The React version gives you better performance and lets you use actual React components for custom blocks, which is much more maintainable than string manipulation.

Key Takeaway

The most important thing to remember: toHTML(portableTextArray, options) - pass your array directly as the first argument, then use the components option in the second argument to customize how each piece renders. Your Portable Text is stored as structured JSON, and the components tell the library how to turn that JSON into HTML.

Hope this clears things up! Let me know if you hit any snags.

Show original thread
13 replies
Could you please try commenting out the third if statement and block (text.length &gt; 1) and removing the destructuring in the
let bt
line (so
let bt = toHTML(text)
)?
k, but that is removing the return statement that displays anything
so now I get nothing
Well, technically I get
<p class="body-text col-6"></p>
When those lines aren't commented I get this:Unknown block type "undefined", specify a component for it in the
components.types
option
I see. I had tried implementing a pared down version of what you posted, so perhaps I misunderstood your code.
Where is
blocks
coming from in this?

if (text.length > 1) {
  return blocks
  .map(block => block.children.map(child => child.text).join(''));
}

So, that code is a bit of a mess becuase I was starting to replace the old code - and in doing so probably made mistakes. Here is the original code with the old blocks to html:

import blocksToHtml from '@sanity/block-content-to-html';

const BodyText = ({ text, layoutSettings } = {}) => {
  let className = 'body-text';
  let ls = 'col-6';
  if (layoutSettings?.sizing) {
    ls = layoutSettings.sizing;
    className = `${className} ${ls}`;
  }

  if (!text) {
    return `<p class="${className}"></p>`;
  }

  if (text.length > 1) {
    return blocksToHtml({
      blocks: text,
      className: className,
    });
  }

  let bt = blocksToHtml({ blocks: text });
  bt = bt.replace(/<p/, `<p class="${className}" `);
  return bt;
};

export default BodyText;
Oh, and I was looking at this info and copied and pasted that.. but looking at it now, I am realizing what I forgot. Regardless, I am still at a loss
I think the new @portabletext/to-html package will correctly handle whether there’s 1 or 1+ blocks (could be wrong, though), but if it doesn’t that should throw a different error.
[NB:
☝️This relates to whether or not you need
if (text.length > 1) {...}
.]
To go back to earlier, can you
console.log(text)
in your BodyText component? If it’s undefined, then your return makes sense, given:

if (!text) {
  return `<p class="${className}"></p>`;
}
For some reason, nothing is showing up in the console no matter where i put the console.log and what I put in it
Hi Stevey. I just tested the following in a React app and it rendered the HTML. Hopefully it can be a starting point for your migration from the old package to the new one.

import {useEffect, useState} from 'react';
import {toHTML} from '@portabletext/to-html';
import configuredClient from './client';

export default function BodyText() {
	const [pt, setPt] = useState();

	useEffect(() => {
		client.fetch(`*[_id == '92fae10c-3209-4b28-bedd-94df0df526c3'].body[]`)
			.then(res => setPt(res))
	}, [])	
	
	return pt && (
		<div>{toHTML(pt)}</div>
	)
}
user A
Thank you so much for that, it definitely helped! I was out yesterday, so I didn't get a chance to mess with it until this morning. Here is the code that worked for me (in case anyone looks at this thread)

import {toHTML} from '@portabletext/to-html';

const BodyText = ({ text, layoutSettings } = {}) => {
  let className = 'body-text';
  let ls = 'col-6';
  if (layoutSettings?.sizing) {
    ls = layoutSettings.sizing;
    className = `${className} ${ls}`;
  }

  if (!text) {
    return `<p class="${className}"></p>`;
  }


  if (text.length > 1) {
    return toHTML(text);
  }

  let bt = toHTML(text);
  bt = bt.replace(/<p/, `<p class="${className}" `);
  return bt;
};

export default BodyText;
You’re welcome! Thank you for following up with your final code, which is admittedly more relevant to someone using that package than my React snippet. Glad you got this working; have a great weekend!

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?