I recently came across a post from Sanity discussing how to present images hosted on their platform. It's a very well-written guide, but it's lacking one major thing: it doesn't cover how to present images embedded within Portable Text blocks.
The problem with this is that the approached laid out in that article will not work for fields that are intended to represent things like article bodies. Such fields are portable text fields in Sanity; they are basically just an indexed array of blocks where each block is a self-contained chunk of content, such as headings, individual paragraphs, and yes, images.
This guide assumes you already have a portable text field configured and ready to be customized. The core information for customizing a portable text field is covered in this article from Sanity. However, it doesn't appear to cover how to actually add an image to your configuration.
To add image support to a portable text field configuration, simply add this to the of
array within the field configuration:
// Example portable text config shared object
const portableTextConfig = {
type: `array`,
of: [
// Other blocks...
{
type: `image` // That's it!
}
]
}
Once you have this in place, when you run your studio, you should see the image button in the rich text editor control bar:
For portable text fields, the amount of information you get back for images is far more limited than you would for an image that is a field directly on a schema type. This means that further steps must be taken in order to actually use the image within something like an article body.
By default, the only data you have access to for images embedded within a Portable Text component looks like this:
{
_type: "image",
_key: "some_block_id",
asset: {
_ref: "some_image_id",
_type: "reference"
}
}
You'll notice that this isn't very much data. By itself, it's not very useful; there is no way to use this data to display the corresponding image without making another request.
When you have an image embedded within a Portable Text field, you may wonder where that image is actually stored since there is no image document listed in your schema.
Sanity stores images in an internally-defined schema type, sanity.imageAsset
. You only need the image ID in order to query its full image data. Fortunately, this is available to you already! It's the value stored in the base block data, asset._ref
. That's the only value you need in order to query the image.
The syntax to query for an internal image looks like this:
*[_type == "sanity.imageAsset" && _id == $ref][0]{
_id,
metadata,
url
}
This query does the following:
- Queries your project's
sanity.imageAsset
documents for one with the matching_id
- Grabs the
_id
,metadata
andurl
for the intended image- Metadata has a field,
lqip
, which is a base64 encoding of a 20x20 version of the image - this is useful for placeholders and image blur when lazy loading images
- Metadata has a field,
- There are more fields you can query, but these are the only ones I am currently interested in
Querying embedded images within getStaticProps
If, like me, you're using Next.js to create a static site, you'll want to query the image data during the getStaticProps
method and not from the client (e.g., the browser). The primary reason for this is that, if you query for the image from the client code, each image will take 2 requests to complete; one to get the base image data from the Sanity API and another to actually fetch the image by its URL (which is obtained by the first call in this scenario).
The solution to querying for embedded images is to add some logic to getStaticProps
that modifies the block data for images in such a way that can be used by your client code without needing to make calls to the Sanity API when it comes time to actually display the images.
If you look at the code for my blog, you can see how I am querying for my base article data. The portable text field for this object is both the summary
and body
fields; however, the summary
field never has embedded images, so I don't bother with mapping image blocks for it.
Once I have the image, it's just a matter of evaluating the blocks and replacing the images with objects that have a little more use than what you get out of the box. I use the following logic to replace the default image portable text blocks:
let imgCount = 0
await Promise.all(
article.body.map(async (block, index) => {
if (block._type === `image`) {
imgCount++
const embeddedImage = await client.fetch(`*[_type == "sanity.imageAsset" && _id == $ref][0]{
metadata,
url
}`, { ref: block.asset._ref }) as {
// There are more fields available here, but we only need the lqip field
metadata: {
lqip: string // A 20x20, base64 encoding of the image, useful for placeholders
},
url: string // The URL to the original, full-resolution asset
}
article.body[index] = {
...block,
asset: {
index: imgCount,
url: embeddedImage.url,
blurImg: embeddedImage.metadata.lqip
}
}
} else {
article.body[index] = block
}
})
)
A breakdown of what's going on here:
- Iterate over all of the
body
field portable text blocks - When an
"image"
type is encountered, replace itsasset
field with a new object containing...index
- used for providing unique (if limited)alt
text for the imageurl
- the URL for the imageblurImg
- base64, 20x20 version of the image being requested
Good news! This part is much less involved.
When it comes time to actually display the image, and since you've already done the "hard part" of obtaining the full image data, all you need to do to render the images is add a mapping to the components
object you pass to the PortableText
component.
The mapping for the image
field goes under the types
field:
const components = {
types: {
image: ({value}) => {
return (
// This component is just a wrapper for the next/image component
<ArticleBodyImage
imgIndex={value.asset.index}
imgUrl={value.asset.url}
blurImg={value.asset.blurImg}
/>
)
}
// the rest of your types
},
marks: {
// PortableText marks
},
block: {
// PortableText blocks
}
}
Simply pass the object above in to your PortableText
's component
and you should be able to see the embedded images show up!