Issues with svelte-portabletext: custom components work but standard text fails

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.

Show original thread
16 replies
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?