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!
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).
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'exportdefaultdefineType({
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:
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-editorof:[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.',},],},],}),
Protip
You can override default block types by defining them yourself.
In addition you can deactivate any functionality by setting it to an empty array:
This is what our PTE now looks like: we can add internal and external links as well as block images.
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'constLinkRenderer=(props)=>{// you don't need to pass down the props yourself, Sanity will handle that for youreturn(// 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 presentcontent={<Boxpadding={3}><Textalign="center"size={1}>{`${props.value?.href}`||'No url found'}</Box></Stack>
}// then we define the placement and other optionsplacement="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 */}
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 dataconstsleep=(ms)=>{returnnewPromise((resolve)=>setTimeout(resolve, ms))}constInternalLinkRenderer=(props)=>{// in order to be able to query for data in the studio, you need to setup a client versionconst client =useClient({apiVersion:'2022-10-31',})// we will store the data we queried in a stateconst[reference, setReference]=useState({})// we need to initialise the subscriptionlet subscription
// then get the data from the referenced documentuseEffect(()=>{// 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 }constfetchReference=async(listening =false)=>{
listening &&(awaitsleep(1500))// here we use the sleep timeout function from the beginning of the fileawait 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 constlisten=()=>{
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 leaksreturnfunctioncleanup(){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;
`exportdefault InternalLinkRenderer
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.
As you can see reference does not include the referenced data, but the href value for the external link is there.
Protip
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 /
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.jsximport{ PortableText }from'@portabletext/react'import{ urlForImage }from'lib/sanity.image'import Image from'next/image'constBody=(props)=>{// we pass the content, width & height of the images into each instance of the Body component // content is our array of blocksconst{ content, imgWidth, imgHeight }= props;const customBlockComponents ={// first we tackle our custom block typestypes:{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 itconst imgUrl =urlForImage(value.assset).height(imgHeight).width(imgWidth).url()return<Imagewidth={imgWidth}height={imgHeight}alt={value.alt}src={imgUrl}sizes="100vw"priority={false}/>},},// then we define how the annotations should be renderedmarks:{link:({children, value})=>{const rel =!value.href.startsWith('/')?'noreferrer noopener':undefinedreturn(<ahref={value.href}target='_blank'rel={rel}>{children}</a>)},internalLink:({children, value})=>{return(<ahref={value.href}>{children}</a>)},},}return<PortableTextvalue={content}components={customBlockComponents}/>}exportdefault Body
Getting image source urls image objects
And last but not least: we add the urlForImage functionality setup.
Sanity Composable Content Cloud is the headless CMS that gives you (and your team) a content backend to drive websites and applications with modern tooling. It offers a real-time editing environment for content creators that’s easy to configure but designed to be customized with JavaScript and React when needed. With the hosted document store, you query content freely and easily integrate with any framework or data source to distribute and enrich content.
Sanity scales from weekend projects to enterprise needs and is used by companies like Puma, AT&T, Burger King, Tata, and Figma.
Discover the power of Portable Text with this essential guide. From data structure, serialisation to validation strategies, you'll learn everything you need to harness its potential.