How to properly render Portable Text with toHTML in Sanity?

13 replies
Last updated: Jan 17, 2026
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

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?