Sanity logosanity.ioAll Systems Operational© Sanity 2026
Change Site Theme
Sanity logo

Documentation

    • Overview
    • Platform introduction
    • Next.js quickstart
    • Nuxt.js quickstart
    • Astro quickstart
    • React Router quickstart
    • Studio quickstart
    • Build with AI
    • Content Lake
    • Functions
    • APIs and SDKs
    • Agent Actions
    • Visual Editing
    • Blueprints
    • Platform management
    • Dashboard
    • Studio
    • Canvas
    • Media Library
    • App SDK
    • Content Agent
    • HTTP API
    • CLI
    • Libraries
    • Specifications
    • Changelog
    • User guides
    • Developer guides
    • Courses and certifications
    • Join the community
    • Templates
Developer guides
Overview

  • Develop with AI

    Best practices

  • Query optimization

    Paginating with GROQ
    High performance GROQ

  • Roles and authentication

    Setting up Single Sign-On with SAML
    Third-Party Login (Deprecated)
    OAuth2
    Set up SSO authentication with SAML and Azure/Entra ID
    Set up SSO authentication with SAML and PingIdentity
    Set up SSO authentication with SAML and JumpCloud
    Reconcile users against internal systems
    Restrict Access to Specific Documents
    Setting up a Default Relay State for IdP Initiated - SAML Logins

  • Structured content

    Scalable navigation patterns
    An opinionated guide to Sanity Studio
    Browsing Content How You Want with Structure Builder
    Deciding on fields and relationships
    Create richer array item previews
    Dynamic folder structure using the currentUser and workflow states
    Create a time duration object field
    Level up Your Edit Modal with Next/Previous Navigation Buttons for Array Items
    Create a “coupon generator” string field input
    Managing redirects with Sanity
    Create a document form progress component
    Create an array input field with selectable templates
    Creating a Parent/Child Taxonomy
    Create interactive array items for featured elements
    Create a visual string selector field input
    Create a survey rating number field input
    How to use structured content for page building
    Create a recycling bin for deleted documents via Sanity Functions

  • Frontend integration

    Add live content to your application
    Forms with Sanity
    Vercel Integration
    Build your blog with Astro and Sanity
    How to implement front-end search with Sanity

  • Ecommerce

    Displaying Sanity content in Shopify
    Sanity Connect for Shopify
    Custom sync handlers for Sanity Connect

  • Integrating with other services

    A/B testing with Sanity and Growthbook
    Cookie consent integrations with Sanity
    Integrating external data sources with Sanity
    Klaviyo (email campaigns)
    Developing with Next.js on GitHub Codespaces

  • Adopting Sanity

    How to pitch Sanity.io to your team
    Convincing your clients to go with Sanity.io, rather than a traditional CMS
    Not-profit plan
    Agencies: Navigating the Spring 2025 Organization Changes
    How to generate massive amounts of demo content for Sanity
    How to implement Multi-tenancy with Sanity

  • GROQ

    GROQ-Powered Webhooks – Intro to Filters
    GROQ-Powered Webhooks – Intro to Projections

  • Portable Text

    Presenting Portable Text
    Add Inline blocks for the Portable Text Editor
    Beginners guide to Portable Text
    How to add custom YouTube blocks to Portable Text
    Converting Inline Styles to Sanity Block Decorators
    Add things to Portable Text
    Change the height of the PTE

  • Community and ecosystem

    Create your own Sanity template
    Community guides
    Community Code of Conduct
    Contribute to the ecosystem

  • Plugin development

    Migrating plugins to support Content Releases

On this page

Previous

Converting Inline Styles to Sanity Block Decorators

Next

Change the height of the PTE

Was this page helpful?

On this page

  • Setting up block content and adding custom blocks in your schemas
  • Step 1: Adding an image block with alt text
  • Step 2: Adding external and internal links as annotations
  • Customising the block content array
  • Step 3: Adding custom renderer for the PTE
  • Creating a LinkRenderer component
  • Setting a custom annotation component for the link object
  • Defining a renderer and a custom annotation component for internal links
  • Step 4: Setting up the query for PT and the custom block data
  • Query parts and exports
  • Step 5: Using the data in the PortableText component
  • Getting image source urls image objects
Developer guidesLast updated September 24, 2025

Adding things to Portable Text - From block content schema to React component

This Guide will lead you through the all the steps you need to level-up your use of Portable Text: from setting up block content, adding custom blocks and renderers for the Portable Text Editor in your studio. But also help you query for everything and render your awesome content in React!

This developer guide was contributed by Saskia Bobinska (Senior Support Engineer).

This guide will lead through the steps you need to level-up your use of block content: setting up a block content schema and adding custom blocks and annotations to it. Then we will configure renderers for the Portable Text Editor in your studio, which will help users see their content enrichment inline. In addition we will also learn how to query the custom image blocks & annotations and set up serialisers so you can render your awesome content in React!

You might also be interested in this guide on adding inline blocks to portable text.

Setting up block content and adding custom blocks in your schemas

The first step in this journey will be setting up block content and adding some custom blocks and annotations for internal and external links. External links (url based annotations of type link) are now part of the default config of the portable text editor (block content).

Look through the Code in TS

From now on we will use PT for Portable Text and PTE for Portable Text Editor.

PT and block content can be used exchangeably in most cases, but block content refers more to the schema of an array of blocks, while PT is mostly used to describe the JSON based output created in the PTE.

Step 1: Adding an image block with alt text

import { defineType } from 'sanity'

export default defineType({
  name: 'content',
  type: 'array',
  title: 'Content',
  of: [
    {
      type: 'block'
    },
    // this is our first custom block which will make it possible to add block images with alt text fields into your portable text
    {
      type: 'image',
        fields: [
          {
            name: 'alt',
            type: 'string',
            title: 'Alternative text',
            description: 'Important for SEO and accessiblity.',
              options: {
                isHighlighted: true,
            },
          },
        ],
    }
  ]
})

Our PTE (portable text editor) toolbar will look like this now:

Loading...
Portable Text Editor Toolbar with external link annotation and image block

When we add an external link, we are asked to paste in a url. But what if we want to validate for certain url types and more?
we might need to add some logic to the existing link. And since we want to also link to internal pages, we will set this up in the next step as well.

Step 2: Adding external and internal links as annotations

So let's add internal links and some more validation to the annotations.

In order to customise the default link annotation, we need to define it in our schema as well as the internal page reference (internalLink).

Customising the block content array

// if you want to have a re-usable blockContent type, 
 //you need to create an object and add this as fields. 
 // In our case we are using this block content array directly in our page schema as a field.
 
 defineField({
    name: 'content',
    title: 'Content',
    type: 'array',
    of: [
      {
        type: 'block',
        
        // INLINE BLOCKS
        // to understand what this does, visit: https://www.sanity.io/guides/add-inline-blocks-to-portable-text-editor
        of: [
          defineField({
            name: 'authorReference',
            type: 'reference',
            to: [{ type: 'author' }],
          }),
        ],
        
        // Let's add some custom annotations by setting marks.annotations
          marks: {
            annotations: [
            //this is our external link object which we override from the default by defining it
              {
                name: 'link',
                type: 'object',
                title: 'Link',
                fields: [
                  {
                    name: 'href',
                    type: 'url',
                    validation: (Rule) =>
                      Rule.uri({
                        allowRelative: false,
                        scheme: ['http', 'https', 'mailto', 'tel'],
                      }),
                  },
                ],
              },

            // this is our internal link object which is a reference to page documents
              {
                name: 'internalLink',
                type: 'object',
                title: 'Link internal page',
                // we can add the icon which will show in the toolbar by importing an icon from a library or pasting in a react component.
                // we use import { LinkIcon } from '@sanity/icons' in this case
                icon: LinkIcon,
                fields: [
                  {
                    name: 'reference',
                    type: 'reference',
                    title: 'Reference',
                    to: [{ type: 'page' }],
                  },
                ],
              },
            ],
          },
        },
        {
          type: 'image',
          fields: [
            {
              name: 'alt',
              type: 'string',
              title: 'Alternative text',
              description: 'Important for SEO and accessiblity.',
            },
          ],
        },
      ],
    }),

You can override default block types by defining them yourself.

In addition you can deactivate any functionality by setting it to an empty array:

..., styles: [], decorators: [], ...

You can find out more options here but in general: if you can define it, you can deactivate it.

This is what our PTE now looks like: we can add internal and external links as well as block images.

Loading...
The blue (underlined) text is an external link, the grey (dotted underlined) text is a reference to another page doc and we can see that the 2 link annotations as well as the block images appear in the toolbar.
Loading...
external link annotation with the opened edit modal, which exposes the url field defined in the schema
Loading...
Internal link annotation with the opened edit modal, which exposes the reference (to a page) field defined in the schema

Neat! Let's say you want to make sure users can see which pages are linked to in the PTE directly without having to click on the annotation. We can achieve this by adding a custom renderer for the PTE.

Step 3: Adding custom renderer for the PTE

So let's start with the external link annotation. So we have a way to show the href when needed but not disrupt the flow of reading. A good way to do this is using a ToolTip component from the Sanity UI, which will appear on hover. In addition we will add a LinkIcon in front of the annotated text.

Creating a LinkRenderer component

I would create a components folder in the root of your studio, but you can add this in any other part of your repo.

import { LinkIcon } from '@sanity/icons'
import { Bock, Text, Tooltip } from '@sanity/ui'
import styled from 'styled-components'

const LinkRenderer = (props) => {
  // you don't need to pass down the props yourself, Sanity will handle that for you
  
  return (
    // the ToolTip component wraps the annotation 
    <Tooltip
    //we define the content in a Box, so we can add padding, and Text where we pass the href value in if present
      content={
        <Box padding={3}>
          <Text align="center" size={1}>
            {`${props.value?.href}` || 'No url found'}
          </Box>
        </Stack>
      }
      // then we define the placement and other options
      placement="bottom"
      fallbackPlacements={['right', 'left']}
      portal
    >
    
    {/* InlineAnnotation is a styled span element, which we use to add padding. */}
      <InlineAnnotation>
        <LinkIcon /> 
        {/*  renderDefault() is needed to let the studio handle the functionality of the annotation. 
          * In V2 you will only pass in props?.children  */}
        <>{props.renderDefault(props?.children)}</>
        
      </InlineAnnotation>
    </Tooltip>
  )
}

const InlineAnnotation = styled.span`
  padding-left: 0.3em;
  padding-right: 0.2em;
`
export default LinkRenderer

Now we can add the LinkRenderer as a custom component to the link annotation in our block content array.

Setting a custom annotation component for the link object

...,
defineField({
  name: 'link',
  type: 'object',
  title: 'Link',
  fields: [
    {
      name: 'href',
      type: 'url',
      title: 'Url',
      validation: (Rule) =>
        Rule.uri({
          allowRelative: false,
          scheme: ['http', 'https', 'mailto', 'tel'],
          }),
    },
  ],
  components: {
    annotation: LinkRenderer,
  },
}),
...

Defining a renderer and a custom annotation component for internal links

Let's do the same for internal links. A caveat here is, that the internal link is a reference so we need to fetch some of the referenced document data for our ToolTip component. If we don't do that we will only get the _id of the referenced document in reference._ref.

In addition we want to setup a listener and make sure we fetch the data a bit delayed, so we can make sure, our data has been able to be stored in the content lake.

import { LinkIcon } from '@sanity/icons'
import { Stack, Text, Tooltip } from '@sanity/ui'
import { useEffect, useState } from 'react'
import { useClient } from 'sanity'
import styled from 'styled-components'

// This is a basic setTimeout function which we will use later to delay fetching our referenced data
const sleep = (ms) => {
  return new Promise((resolve) => setTimeout(resolve, ms))
}

const InternalLinkRenderer = (props) => {
  // in order to be able to query for data in the studio, you need to setup a client version
  const client = useClient({
    apiVersion: '2022-10-31',
  })
  
  // we will store the data we queried in a state
  const [reference, setReference] = useState({})

  // we need to initialise the subscription
  let subscription
  // then get the data from the referenced document
  useEffect(() => {
    // so let's setup the query and params to fetch the values we need.
    const query = `*[_id == $rev]{title, 'slug': slug.current}[0]`
    const params = { rev: props.value.reference?._ref }
    
    const fetchReference = async (listening = false) => {
      listening && (await sleep(1500)) // here we use the sleep timeout function from the beginning of the file
      
      await client
        .fetch(query, params)
        .then((res) => {
          setReference(res)
        })
        .catch((err) => {
          console.error(err.message)
        })
    }
    
    // since we store our referenced data in a state we need to make sure, we also get changes 
    const listen = () => {
      subscription = client
        .listen(query, params, { visibility: 'query' })
        .subscribe(() => fetchReference(true))
    }
    fetchReference().then(listen)
    
    // and then we need to cleanup after ourselves, so we don't get any memory leaks
    return function cleanup() {
      if (subscription) {
        subscription.unsubscribe()
      }
    }
  }, [])

  return (
    <Tooltip
      content={
        <Stack space={2} padding={3}>
          <Text align="center" size={1}>
            {`${reference.title}` || 'No title or slug found'}
          </Text>
          <Text align="center" size={1} muted>
            {`Slug: /${reference.slug}` || ''}
          </Text>
        </Stack>
      }
      fallbackPlacements={['right', 'left']}
      placement="bottom"
      portal
    >
      <InlineAnnotation>
        <LinkIcon /> <>{props.renderDefault(props.children)}</>
      </InlineAnnotation>
    </Tooltip>
  )
}
const InlineAnnotation = styled.span`
  padding-left: 0.3em;
  padding-right: 0.2em;
`
export default InternalLinkRenderer
...,
  defineField({
    name: 'internalLink',
    type: 'object',
    title: 'Link internal page',
    icon: LinkIcon,
    components: {
      annotation: InternalLinkRenderer,
    },
    fields: [
      {
        name: 'reference',
        type: 'reference',
        title: 'Reference',
        to: [{ type: 'page' }],
      },
    ],
  }),
...

Let's look at the results!

Loading...
Awesome! We got everything setup and the tool-tips appear on hover with the referenced document data

Step 4: Setting up the query for PT and the custom block data

Now that we have everything set up we need to make sure we also query the custom block data not included in the PT output. Annotation data is stored in a markDef array at the end of each block.

...,
"_type": "block",
"children": [...],
"markDefs": [
  {
    "_key": "21647abff82b",
    "_type": "link",
    "href": "https://www.sanity.io/guides/add-inline-blocks-to-portable-text-editor"
  },
  {
    "_key": "c0465b1db68e",
    "_type": "internalLink",
    "reference": {
      "_ref": "abc0397c-ca33-4fba-97bb-1717e86e7261",
      "_type": "reference"
    }
  }
],
...

As you can see reference does not include the referenced data, but the href value for the external link is there.

You can get the data of your portable text editor by using the inspect tool in the document you're working in by pressing crtl + alt + i or the right elipsis ... menu.

In order to get this referenced data for our front-end we need to setup a GROQ query using joins. But since the reference is inside an array (markDefs) inside of an array (blocks) we need to use a special GROQ syntax for dereferencing and accessing certain types in arrays.

A best practice to handle queries like that (which you will need to reuse wherever PT is part of the data) is to define field queries separately and import them into the page queries.

Query parts and exports

With this set up you can import the queries in your pages or components, and can easily add changes when needed.

// lib/sanity.queries.ts

const bodyField = `
  body[]{
    ...,
    // add your custom blocks here (we don't need to do that for images, because we will get the image url from the @sanity/image-url package)
    
    markDefs[]{ 
        // so here we make sure to enclude all other data points are included
        ..., 
        // then we define that if a child of the markDef array is of the type internalLink, we want to get the referenced doc value of slug and combine that with a / 
        _type == "internalLink" => { "href": "/"+ @.reference-> slug.current },
        },
  }
`
const pageFields = `
  _id,
  title,
  description,
  "slug": slug.current,
  ${bodyField}
`

export const pageQuery = `
*[_type == "page" && slug.current == $slug][0] {
  ${pageFields}
}
`

Step 5: Using the data in the PortableText component

Now that we have our data ready we can use them in our front-end. But, we need to define, how the PT data should be rendered. Fortunately there are a couple of packages we can use to serialise PT. In React based Frameworks, we can use @portabletext/react (Repo)

// Body.jsx
import { PortableText } from '@portabletext/react'
import { urlForImage } from 'lib/sanity.image'
import Image from 'next/image'

const Body = (props)=> {
  // we pass the content, width & height of the images into each instance of the Body component 
  // content is our array of blocks
  const { content, imgWidth, imgHeight } = props;
  
  const customBlockComponents = {
  // first we tackle our custom block types
  types: {
    image: ({value}) => {
      // we need to get the image source url, and since @sanity/image-url will give us optimised images for each instance we use it
      const imgUrl = urlForImage(value.assset).height(imgHeight).width(imgWidth).url()
      
      return <Image
            width={imgWidth}
            height={imgHeight}
            alt={value.alt}
            src={imgUrl}
            sizes="100vw"
            priority={false}
          />
    },
  },

  // then we define how the annotations should be rendered
  marks: {
    link: ({children, value}) => {
      const rel = !value.href.startsWith('/') ? 'noreferrer noopener' : undefined
      return (
        <a href={value.href} target='_blank' rel={rel}>
          {children}
        </a>
      )
    },
    internalLink: ({children, value}) => {
      return (
        <a href={value.href}>
          {children}
        </a>
      )
    },
  },
}

return <PortableText
  value={content}
  components={customBlockComponents}
/>
}
export default Body

Getting image source urls image objects

And last but not least: we add the urlForImage functionality setup.

// lib/sanity.image.ts

import createImageUrlBuilder from '@sanity/image-url'

const projectId = process.env.NEXT_PUBLIC_SANITY_PROJECT_ID
const dataset = process.env.NEXT_PUBLIC_SANITY_DATASET

const imageBuilder = createImageUrlBuilder({ projectId, dataset })

export const urlForImage = (source) =>
  imageBuilder.image(source).auto('format').fit('max')

And we're done 🥳

If you prefer to use TS checkout these snippets

import { defineType } from 'sanity'

export default defineType({
  name: 'content',
  type: 'array',
  title: 'Content',
  of: [
    {
      type: 'block'
    },
    // this is our first custom block which will make it possible to add block images with alt text fields into your portable text
    {
      type: 'image',
        fields: [
          {
            name: 'alt',
            type: 'string',
            title: 'Alternative text',
            description: 'Important for SEO and accessiblity.',
              options: {
                isHighlighted: true,
            },
          },
        ],
    }
  ]
})
Screenshot of Portable Text Editor Toolbar with external link annotation and image block icons
// if you want to have a re-usable blockContent type, 
 //you need to create an object and add this as fields. 
 // In our case we are using this block content array directly in our page schema as a field.
 
 defineField({
    name: 'content',
    title: 'Content',
    type: 'array',
    of: [
      {
        type: 'block',
        
        // INLINE BLOCKS
        // to understand what this does, visit: https://www.sanity.io/guides/add-inline-blocks-to-portable-text-editor
        of: [
          defineField({
            name: 'authorReference',
            type: 'reference',
            to: [{ type: 'author' }],
          }),
        ],
        
        // Let's add some custom annotations by setting marks.annotations
          marks: {
            annotations: [
            //this is our external link object which we override from the default by defining it
              {
                name: 'link',
                type: 'object',
                title: 'Link',
                fields: [
                  {
                    name: 'href',
                    type: 'url',
                    validation: (Rule) =>
                      Rule.uri({
                        allowRelative: false,
                        scheme: ['http', 'https', 'mailto', 'tel'],
                      }),
                  },
                ],
              },

            // this is our internal link object which is a reference to page documents
              {
                name: 'internalLink',
                type: 'object',
                title: 'Link internal page',
                // we can add the icon which will show in the toolbar by importing an icon from a library or pasting in a react component.
                // we use import { LinkIcon } from '@sanity/icons' in this case
                icon: LinkIcon,
                fields: [
                  {
                    name: 'reference',
                    type: 'reference',
                    title: 'Reference',
                    to: [{ type: 'page' }],
                  },
                ],
              },
            ],
          },
        },
        {
          type: 'image',
          fields: [
            {
              name: 'alt',
              type: 'string',
              title: 'Alternative text',
              description: 'Important for SEO and accessiblity.',
            },
          ],
        },
      ],
    }),
Screenshot of portable text edior, with example text: The blue (underlined) text is an external link, the grey (dotted underlined) text is a reference to another page doc and we can see that the 2 link annotations as well as the block images appear in the toolbar.
Screenshot showing a pte. The edit modal for an external link annotation is opened, which exposes the url field defined in the schema
Screenshot showing a pte. The edit modal for an internal link annotation is opened, which exposes the reference (to a page doc) field defined in the schema
import { LinkIcon } from '@sanity/icons'
import { Bock, Text, Tooltip } from '@sanity/ui'
import styled from 'styled-components'

const LinkRenderer = (props) => {
  // you don't need to pass down the props yourself, Sanity will handle that for you
  
  return (
    // the ToolTip component wraps the annotation 
    <Tooltip
    //we define the content in a Box, so we can add padding, and Text where we pass the href value in if present
      content={
        <Box padding={3}>
          <Text align="center" size={1}>
            {`${props.value?.href}` || 'No url found'}
          </Box>
        </Stack>
      }
      // then we define the placement and other options
      placement="bottom"
      fallbackPlacements={['right', 'left']}
      portal
    >
    
    {/* InlineAnnotation is a styled span element, which we use to add padding. */}
      <InlineAnnotation>
        <LinkIcon /> 
        {/*  renderDefault() is needed to let the studio handle the functionality of the annotation. 
          * In V2 you will only pass in props?.children  */}
        <>{props.renderDefault(props?.children)}</>
        
      </InlineAnnotation>
    </Tooltip>
  )
}

const InlineAnnotation = styled.span`
  padding-left: 0.3em;
  padding-right: 0.2em;
`
export default LinkRenderer
...,
defineField({
  name: 'link',
  type: 'object',
  title: 'Link',
  fields: [
    {
      name: 'href',
      type: 'url',
      title: 'Url',
      validation: (Rule) =>
        Rule.uri({
          allowRelative: false,
          scheme: ['http', 'https', 'mailto', 'tel'],
          }),
    },
  ],
  components: {
    annotation: LinkRenderer,
  },
}),
...
import { LinkIcon } from '@sanity/icons'
import { Stack, Text, Tooltip } from '@sanity/ui'
import { useEffect, useState } from 'react'
import { useClient } from 'sanity'
import styled from 'styled-components'

// This is a basic setTimeout function which we will use later to delay fetching our referenced data
const sleep = (ms) => {
  return new Promise((resolve) => setTimeout(resolve, ms))
}

const InternalLinkRenderer = (props) => {
  // in order to be able to query for data in the studio, you need to setup a client version
  const client = useClient({
    apiVersion: '2022-10-31',
  })
  
  // we will store the data we queried in a state
  const [reference, setReference] = useState({})

  // we need to initialise the subscription
  let subscription
  // then get the data from the referenced document
  useEffect(() => {
    // so let's setup the query and params to fetch the values we need.
    const query = `*[_id == $rev]{title, 'slug': slug.current}[0]`
    const params = { rev: props.value.reference?._ref }
    
    const fetchReference = async (listening = false) => {
      listening && (await sleep(1500)) // here we use the sleep timeout function from the beginning of the file
      
      await client
        .fetch(query, params)
        .then((res) => {
          setReference(res)
        })
        .catch((err) => {
          console.error(err.message)
        })
    }
    
    // since we store our referenced data in a state we need to make sure, we also get changes 
    const listen = () => {
      subscription = client
        .listen(query, params, { visibility: 'query' })
        .subscribe(() => fetchReference(true))
    }
    fetchReference().then(listen)
    
    // and then we need to cleanup after ourselves, so we don't get any memory leaks
    return function cleanup() {
      if (subscription) {
        subscription.unsubscribe()
      }
    }
  }, [])

  return (
    <Tooltip
      content={
        <Stack space={2} padding={3}>
          <Text align="center" size={1}>
            {`${reference.title}` || 'No title or slug found'}
          </Text>
          <Text align="center" size={1} muted>
            {`Slug: /${reference.slug}` || ''}
          </Text>
        </Stack>
      }
      fallbackPlacements={['right', 'left']}
      placement="bottom"
      portal
    >
      <InlineAnnotation>
        <LinkIcon /> <>{props.renderDefault(props.children)}</>
      </InlineAnnotation>
    </Tooltip>
  )
}
const InlineAnnotation = styled.span`
  padding-left: 0.3em;
  padding-right: 0.2em;
`
export default InternalLinkRenderer
...,
  defineField({
    name: 'internalLink',
    type: 'object',
    title: 'Link internal page',
    icon: LinkIcon,
    components: {
      annotation: InternalLinkRenderer,
    },
    fields: [
      {
        name: 'reference',
        type: 'reference',
        title: 'Reference',
        to: [{ type: 'page' }],
      },
    ],
  }),
...
Screenshot of the pte with an open tooltip, which displays the data of the referenced document from internalLink
...,
"_type": "block",
"children": [...],
"markDefs": [
  {
    "_key": "21647abff82b",
    "_type": "link",
    "href": "https://www.sanity.io/guides/add-inline-blocks-to-portable-text-editor"
  },
  {
    "_key": "c0465b1db68e",
    "_type": "internalLink",
    "reference": {
      "_ref": "abc0397c-ca33-4fba-97bb-1717e86e7261",
      "_type": "reference"
    }
  }
],
...
// lib/sanity.queries.ts

const bodyField = `
  body[]{
    ...,
    // add your custom blocks here (we don't need to do that for images, because we will get the image url from the @sanity/image-url package)
    
    markDefs[]{ 
        // so here we make sure to enclude all other data points are included
        ..., 
        // then we define that if a child of the markDef array is of the type internalLink, we want to get the referenced doc value of slug and combine that with a / 
        _type == "internalLink" => { "href": "/"+ @.reference-> slug.current },
        },
  }
`
const pageFields = `
  _id,
  title,
  description,
  "slug": slug.current,
  ${bodyField}
`

export const pageQuery = `
*[_type == "page" && slug.current == $slug][0] {
  ${pageFields}
}
`
// Body.jsx
import { PortableText } from '@portabletext/react'
import { urlForImage } from 'lib/sanity.image'
import Image from 'next/image'

const Body = (props)=> {
  // we pass the content, width & height of the images into each instance of the Body component 
  // content is our array of blocks
  const { content, imgWidth, imgHeight } = props;
  
  const customBlockComponents = {
  // first we tackle our custom block types
  types: {
    image: ({value}) => {
      // we need to get the image source url, and since @sanity/image-url will give us optimised images for each instance we use it
      const imgUrl = urlForImage(value.assset).height(imgHeight).width(imgWidth).url()
      
      return <Image
            width={imgWidth}
            height={imgHeight}
            alt={value.alt}
            src={imgUrl}
            sizes="100vw"
            priority={false}
          />
    },
  },

  // then we define how the annotations should be rendered
  marks: {
    link: ({children, value}) => {
      const rel = !value.href.startsWith('/') ? 'noreferrer noopener' : undefined
      return (
        <a href={value.href} target='_blank' rel={rel}>
          {children}
        </a>
      )
    },
    internalLink: ({children, value}) => {
      return (
        <a href={value.href}>
          {children}
        </a>
      )
    },
  },
}

return <PortableText
  value={content}
  components={customBlockComponents}
/>
}
export default Body
// lib/sanity.image.ts

import createImageUrlBuilder from '@sanity/image-url'

const projectId = process.env.NEXT_PUBLIC_SANITY_PROJECT_ID
const dataset = process.env.NEXT_PUBLIC_SANITY_DATASET

const imageBuilder = createImageUrlBuilder({ projectId, dataset })

export const urlForImage = (source) =>
  imageBuilder.image(source).auto('format').fit('max')