Naalya Handbook
Sanity CMS

Images & Portable Text

Rendering Sanity images with the image-url builder and OptimizedImage, plus Portable Text rich text through a components map.

A GROQ query hands you raw JSON. But two kinds of content can't just be dropped into JSX and called done — images and rich text. An image from Sanity isn't a URL; it's an asset reference plus a focal point and crop. Rich text isn't a string; it's a tree of typed blocks called Portable Text. Both need a translation layer between "what Sanity stores" and "what the browser renders."

This page covers both layers. They share a theme: the query has to project the right shape, or the renderer quietly gives you nothing. Get the projection right and the rest is mechanical.

This builds on the read path

Everything here assumes content has already arrived via a query module and TanStack Query. If mainImage or body isn't reaching your component at all, the problem is upstream — start at Querying with GROQ.


Why an image needs more than a URL

When an editor uploads an image in the Studio and drags the little circle to pick a focal point, Sanity stores three things: a reference to the asset (the actual file), a hotspot (the focal point, as fractions), and a crop (how much to trim off each edge). The CDN can honor all of that — serve a 640px-wide WebP cropped around the editor's chosen face — but only if your code passes it the whole object.

That's why every image projection across the codebase looks identical. Copy it verbatim so the frontend always gets the URL, the blur placeholder, the dimensions, and the crop data:

app/queries/landing/landing.query.ts
mainImage{
  asset->{
    _id,
    url,
    metadata {
      dimensions { width, height, aspectRatio },
      lqip,
      palette { background, foreground }
    }
  },
  hotspot,
  crop
}

Each piece earns its place. asset->{ url } is the CDN URL the builder transforms. metadata.lqip is a base64 blur shown while the full image loads. metadata.dimensions lets the <img> reserve space and avoid layout shift. And crucially, hotspot / crop stay on the image object, not the asset — because that's where the builder looks for them.

Forget the asset sub-projection and images render nothing

The helpers and <OptimizedImage> short-circuit to "" / null the moment image.asset.url is missing. Omit asset->{ url, metadata{...} } from a query and images render as blank space — no blur, no layout-shift protection, and no error in the console. Just nothing.

The TypeScript shape that mirrors this projection lives in one place and is imported app-wide — by the helpers, by OptimizedImage, by every DTO with an image field. For any new image field, import ImageAsset rather than redefining it:

app/queries/landing/interfaces/landing.dto.ts
export type ImageAsset = {
  asset?: { _id?: string; url?: string; metadata?: ImageMetadata };
  hotspot?: { x?: number; y?: number; width?: number; height?: number };
  crop?: { top?: number; bottom?: number; left?: number; right?: number };
};

Building image URLs in one place

All urlFor-style URL building lives in exactly one file: app/helpers/optimize-sanity-image.ts. It creates the builder once from the shared client, then exposes a handful of helpers. Nothing else in the app touches @sanity/image-url directly.

The core helper takes an ImageAsset and an options bag, then chains the builder. The detail that matters most is on the first builder line:

app/helpers/optimize-sanity-image.ts
const builder = imageUrlBuilder(sanityClient);

export function optimizeSanityImage(image, options = {}): string {
  if (!image?.asset?.url) return ""; // short-circuit when the projection is incomplete

  let urlBuilder = builder.image(image); // pass the WHOLE image, not just the url
  if (options.width) urlBuilder = urlBuilder.width(options.width);
  if (options.auto) urlBuilder = urlBuilder.auto(options.auto); // "format" -> WebP/AVIF
  /* ...height, quality, format, fit, blur, dpr applied the same way */
  return urlBuilder.url();
}

Pass the whole image object — that's the point of keeping hotspot/crop

builder.image(image) receives the entire ImageAsset, hotspot and crop included, which is the whole reason the projection keeps those fields — the CDN crops around the editor's focal point instead of dead-centering. And auto: "format" lets Sanity serve WebP or AVIF based on the browser's Accept header, for free.

Three companion helpers round it out. You rarely call them by hand — OptimizedImage composes all four — but knowing what they return helps when you need a raw value:

HelperReturns
optimizeSanityImage(image, opts)A single transformed URL string.
generateSrcSet(image, sizes?, opts?)A "<url> <w>w, ..." srcSet across responsive widths.
getLQIP(image)The base64 blur placeholder, or "".
getImageDimensions(image){ width, height, aspectRatio }, or null.

The responsive widths come from a shared IMAGE_SIZES constant (mobile: 640 through ultrawide: 1920), so every image on the site uses the same breakpoints — which means the CDN caches them once and serves them everywhere.


Render with OptimizedImage, not the builder

Here's the rule: components almost never call the builder directly. They render <OptimizedImage> (app/components/ui/optimized-image.tsx), which stitches all four helpers into a final <img srcSet sizes width height loading> with an LQIP blur sitting behind it. You hand it a Sanity ImageAsset and a few props; it handles format conversion, responsive sources, dimensions, and the blur-up.

app/routes/landing.tsx
import { OptimizedImage } from "@/components/ui/optimized-image";
import { IMAGE_SIZES } from "@/helpers/optimize-sanity-image";

<OptimizedImage
  image={heroImage}        // a Sanity ImageAsset straight from a query
  alt="Students on campus"
  width={IMAGE_SIZES.wide}
  priority
  loading="eager"
/>;

For an above-the-fold hero, pass priority and loading="eager"; for anything below the fold, the default lazy loading is what you want. There's a full prop list (fillContainer, objectFit, sizes, custom srcSetWidths) in Components.

When to drop down to the raw URL

Reach for optimizeSanityImage(image, {...}) only when an <img> won't do — a CSS background-image, or an Open Graph <meta> tag in your route's meta(). Those need a string, not a React element. For everything that ends up on screen as a picture, use OptimizedImage.


Portable Text: rich text as a tree, not a string

The other shape that can't go straight into JSX is block content — the rich-text body of a blog post. Sanity stores it as Portable Text: an array of typed blocks (paragraphs, headings, lists, embedded images), each with marks for bold, links, and so on. You render it with @portabletext/react's <PortableText>, which walks that tree and turns each block into an element.

The only place the site renders Portable Text is the blog post route, app/routes/blog-post.tsx — so if you're looking for a working example, that's the file.

Step 1: Project the body, including inline images

Before you can render it, the query has to project the body array. The subtlety is embedded images: a body can contain image blocks, and those need the same full asset metadata as any other image. So the projection spreads everything, then conditionally adds the asset projection only to image blocks:

app/queries/blog-post/blog-post.query.ts
body[]{
  ...,
  _type == "image" => {
    ...,
    asset->{
      _id,
      url,
      metadata { dimensions { width, height, aspectRatio }, lqip, palette { background, foreground } }
    }
  }
}

The _type == "image" => { ... } is a conditional projection — it touches only image blocks and leaves text blocks alone. Skip it, and inline images in a post lose their URL and render as blank, exactly like a top-level image with a missing projection.

Step 2: Map block types to components

<PortableText> needs to know how to render each block type. You give it a PortableTextComponents map. In this app the map is declared inside the component, because the heading overrides close over the post body and a table-of-contents helper:

app/routes/blog-post.tsx
import { PortableText, type PortableTextComponents } from "@portabletext/react";
import { OptimizedImage } from "@/components/ui/optimized-image";
import { IMAGE_SIZES } from "@/helpers/optimize-sanity-image";

const portableTextComponents: PortableTextComponents = {
  block: {
    h2: ({ children, value }) => {
      const index = blogPost.body.findIndex((b) => b._key === value._key);
      const headingId = `section-${index}`;
      return (
        <h2 id={headingId} ref={(ref) => addSectionRef(headingId, ref)}>
          {children}
        </h2>
      );
    },
    /* h3 follows the same pattern */
  },
  types: {
    image: ({ value }) => {
      if (!value?.asset) return null;
      return (
        <OptimizedImage
          alt={value.alt || "Blog post image"}
          image={value}
          loading="lazy"
          width={IMAGE_SIZES.desktop}
        />
      );
    },
  },
};

Two override groups carry the weight. The block.h2 / block.h3 overrides add id="section-N" anchors and register a ref so the table of contents can scroll to each heading. The types.image override routes inline images through the same <OptimizedImage> you met above — so a picture embedded mid-article gets the identical WebP, srcSet, and blur treatment as a hero. Note the value.alt || "Blog post image" fallback: defaults live in JavaScript, never in GROQ.

Step 3: Render inside a prose container

Finally, render <PortableText> inside a Tailwind Typography prose wrapper. That's what styles the headings, lists, and paragraphs the renderer emits — without it you'd get unstyled HTML:

app/routes/blog-post.tsx
<article className="prose prose-lg dark:prose-invert my-8 max-w-none">
  <PortableText components={portableTextComponents} value={blogPost.body} />
</article>

Heading anchors break when blocks are reordered

The block.h2 / block.h3 overrides build anchor IDs from blogPost.body.findIndex(...)section-<index>, which is the block's position in the array. Reorder blocks in the Studio and every anchor ID shifts — breaking deep links and the table-of-contents refs. If you need stable anchors, derive the ID from the heading text or the block's _key instead.


The shape that ties it together

Both halves of this page reduce to one habit: the projection determines whether rendering works. An image with no asset->{ url } is blank. A body without the conditional image projection drops its inline pictures. The renderers don't complain — they just produce nothing — so the fix is always to check the query first.

The asset projection is non-negotiable

Every image keeps asset->{ url, metadata }, hotspot, crop — copy it verbatim or images render blank.

One builder, one component

URL building lives only in optimize-sanity-image.ts; on-screen images always go through OptimizedImage.

Portable Text needs a components map

<PortableText> walks the block tree; you map each type, and inline images reuse OptimizedImage.


Where to go next

On this page