Issues with rendering standard text in Svelte PortableText v2 component

16 replies
Last updated: Mar 8, 2023
I hope somebody can point me in the right direction, I’ve been banging my head against the keyboard for to long now! It’s really 3 issues, but I think they’re related.
I’m using
svelte-portabletext v2 to render my Sanity portabletext. I’ve got few custom components, which outputs ok, but the non-custom, standard text is having issues.1. In the console I get the following error:

Unknown list item style "normal", specify a component for it in the `components.listItem` prop
2. In the rendered HTML page everything is there, but if I include a link in a paragraf, the whole paragraf disappears! (I wonder if it’s because I had to make a custom Block from another error asking me to add one?)
3. And most odd, the wrapping div with id=svelte
<div _id_="svelte">%sveltekit.body%</div>
disappears when I reload the page, or enter it directly via URL, but the ``%sveltekit.body%`` still loads. But if I navigate from another page, the wrapping div#svelte is there.
My kitchensink page with the PortableText component:

<script>
import { imageUrlFor } from '$lib/utils/_sanity';
import { PortableText } from '@portabletext/svelte';
import Cta from '$lib/utils/serializers/cta.svelte';
import Link from '$lib/utils/serializers/link.svelte';
import Code from '$lib/utils/serializers/code.svelte';
import ImageBlock from '$lib/utils/serializers/imageBlock.svelte';
import AuthorBlock from '$lib/utils/serializers/authorBlock.svelte';
import Button from '$lib/utils/serializers/button.svelte';
import Vimeo from '$lib/utils/serializers/vimeo.svelte';
import EmbedHTML from '$lib/utils/serializers/embedHTML.svelte';
import Block from '$lib/utils/serializers/block.svelte';
import ListItem from '$lib/utils/serializers/listItem.svelte';
</script>

<PortableText
  value={post.body}
  components={{
    marks: {
      link: Link,
    },
    types: {
      cta: Cta,
      code: Code,
      image: ImageBlock,
      authorReference: AuthorBlock,
      button: Button,
      vimeo: Vimeo,
      embedHTML: EmbedHTML,
      block: Block,
      listItem: {
        bullet: ListItem,
        number: ListItem,
        normal: ListItem
      }
    }
  }}
/>
AI Update

I can help you work through these related issues with @portabletext/svelte! Let me break down what's happening and how to fix it.

Issue 1 & 2: Component Configuration Structure

The main problem is that you have block and listItem incorrectly placed inside the types object. In @portabletext/svelte, the component configuration works like this:

  • types is for custom block types (your cta, image, vimeo, etc.)
  • marks is for inline annotations (like your link)
  • block, list, and listItem should be top-level properties in the components object, not nested under types

Here's the corrected structure:

<PortableText
  value={post.body}
  components={{
    types: {
      // Only custom types here
      cta: Cta,
      code: Code,
      image: ImageBlock,
      authorReference: AuthorBlock,
      button: Button,
      vimeo: Vimeo,
      embedHTML: EmbedHTML,
    },
    marks: {
      link: Link,
    },
    block: {
      // Optional: custom renderers for different block styles
      normal: Block,
      h1: Block,
      h2: Block,
      // etc.
    },
    list: {
      // Optional: custom list container components
      bullet: YourBulletListComponent,
      number: YourNumberListComponent,
    },
    listItem: {
      bullet: ListItem,
      number: ListItem,
      // Note: no "normal" - that's a block style, not a list item style
    }
  }}
/>

About the "Unknown list item style 'normal'" error: This happens because you had listItem nested under types with a normal property. List items don't have a "normal" style - that's a block style for regular paragraphs. The Portable Text specification shows that listItem is a property on blocks (like "listItem": "bullet") to indicate the block is part of a list, not a separate style type. Once you move listItem to the top level of your components configuration and remove normal from it, this error will disappear.

The simplest solution: If you don't need custom styling for blocks and lists, just remove those configurations entirely and let the library use its defaults:

<PortableText
  value={post.body}
  components={{
    types: {
      cta: Cta,
      code: Code,
      image: ImageBlock,
      authorReference: AuthorBlock,
      button: Button,
      vimeo: Vimeo,
      embedHTML: EmbedHTML,
    },
    marks: {
      link: Link,
    }
    // That's it! Default rendering for blocks and lists
  }}
/>

If you keep your custom Block component, make sure it properly renders its children. Your Block component must accept and render the text/children passed to it:

<!-- block.svelte -->
<script>
  export let portableText;
  export let value;
</script>

<p>
  <slot />
</p>

The <slot /> is critical - without it, any inline content (including text with your Link mark component) won't render. This is why paragraphs with links are disappearing.

My recommendation: Unless you absolutely need custom block styling, remove the Block component configuration entirely and let @portabletext/svelte handle it with its defaults. This is the most reliable approach.

Issue 3: SvelteKit Hydration Problem

This is a separate SvelteKit issue unrelated to Portable Text. The symptom - the wrapper appearing on client-side navigation but disappearing on direct page load - indicates a hydration mismatch between server-rendered HTML and client-side expectations.

Check your src/app.html file. The standard SvelteKit structure should look like:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <link rel="icon" href="%sveltekit.assets%/favicon.png" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    %sveltekit.head%
  </head>
  <body data-sveltekit-preload-data="hover">
    <div style="display: contents">%sveltekit.body%</div>
  </body>
</html>

If you've manually added <div id="svelte">, it can cause hydration conflicts. Your options:

  1. Use display: contents on any wrapper (as shown above) - this makes the div transparent to layout
  2. Remove the wrapper entirely and use %sveltekit.body% directly in the body tag
  3. Move your wrapper to a layout component:
<!-- src/routes/+layout.svelte -->
<div id="svelte">
  <slot />
</div>

This ensures the wrapper is part of your Svelte component tree and will hydrate consistently.

Summary

  1. Move block and listItem out of types to be top-level properties in components
  2. Remove normal from listItem (or better yet, remove custom block/listItem configs entirely)
  3. Ensure your Block component has <slot /> or remove it completely
  4. Fix your app.html by using display: contents, removing custom wrappers, or moving them to +layout.svelte

These changes should resolve all three issues! The first two fixes will solve your Portable Text rendering problems, and the third will fix the SvelteKit hydration issue.

jedi move! I was just looking at this right now, again
I’ve narrowed it down to coming from my custom Block element now.
// block.svelte
<script>
	export let portableText
	$: ({value} = portableText)
	$: style = value.style || 'normal'
</script>

{#if style === 'normal'}
	<p>{value}</p>
{:else}
	{value}
{/if}
However, when this is in use I get
[object object]
. So I’m close, but I can’t figure out why I’m getting object and not the contents?
Hi
user S
. What do you get when you log out
value
?
hi! I’m getting
undefined
Hmm 🤔
figures, log portableText and see what’s actually getting passed there
using
{@debug value}
I get
Object { 
value: Object { _key: "09334c0068b5", _type: "block", style: "normal", … }}

Logging portableText:
Output from
JSON.stringify(value, 0, 2)
, so it’s there, I think?
  {
  "_key": "a916b44a5f35",
  "_type": "block",
  "children": [
    {
      "_key": "57e89ed06f2f",
      "_type": "span",
      "marks": [
        "05cf5d422daf"
      ],
      "text": "reMarkable"
    },
    {
      "_key": "451370a506d0",
      "_type": "span",
      "marks": [],
      "text": " is on a quest to help more people think better through technology. "
    }
  ],
  "markDefs": [
    {
      "blank": null,
      "class": null,
      "href": "<http://example.com|example.com>"
    }
  ],
  "style": "normal"
}
But shouldn’t the
markDefs
have a reference to the
_key
? Or say it’s of type
link
?
nah. no good. I’m starting to feel pretty dumb . have waisted so much time on this now
user Q
would you mind enlightening me on why it figures that I get
undefined
when I log out
value
?
But shouldn’t the
markDefs
have a reference to the
_key
? Or say it’s of type
link
?
Indeed, those are not
markDefs
properties I’ve seen before.

would you mind enlightening me on why it figures that I get
undefined
when I log out
value
?
Where are you seeing undefined?
user A
omg thank you! You led me on the right track with those
markDefs
!
A while back I tried creating some custom fields for links, to select opening in new tab or adding a class. So my groq looked like this:

markDefs[]{
  _type == 'link' => {
    blank,
    href,
    class[0]
  }
}
So not everything needed was included. Now adding
however, I get everything working!
markDefs[]{
  _type == 'link' => {
    ...
  }
}
I’m so incredibly relieved now.
Great! Thanks for following up to let us know. Glad to hear you figured it out. 😀
I do not want to know how many hours that cost me 😭

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?