Sanity Image Placeholders Under 50 kB: Palette, Not Base64 LQIP

By Nayan Kyada · · 5 min read

Part of The Sanity + Next.js Guide

I used to ship every Sanity image with a Base64-encoded LQIP embedded in the page HTML. The intent was good—show a blurred placeholder while the full image loads—but the implementation added 2-4 kB per image to the document. On a product grid with twelve images, that's 24-48 kB of inline data before the user sees anything useful.

Sanity's image pipeline can generate LQIP metadata, but the default approach is to fetch it separately or embed it as a data URI. Both patterns hurt either LCP or HTML size. After profiling a dozen production deployments, I landed on a much cheaper placeholder: Sanity's built-in metadata.palette field, which returns a ready-to-use dominant color for free with any image query — no extra fetch, no build step, no custom decoding. Render it as the container background and layer the real next/image on top with a fade-in transition. The hex color is ~20 bytes. LCP improved by 200-400 ms on image-heavy pages.

Why Base64 LQIPs are expensive

When you query Sanity for an image asset and include the lqip field, you get back a Base64-encoded JPEG or WebP string. It looks like this:

*[_type == "product"][0] {
  image {
    asset->{
      url,
      metadata {
        lqip
      }
    }
  }
}

The lqip string is typically 2-4 kB. If you inline it in an <img src="data:image/jpeg;base64,..."> or as a next/image placeholder, that data ships with the HTML. On a page with ten products, you've added 20-40 kB to the document before React hydrates. That delays First Contentful Paint and pushes your LCP element further down the waterfall.

The alternative—fetching the LQIP on the client after mount—introduces a round trip and a visible layout shift. Not acceptable for e-commerce or editorial sites where images are above the fold.

Skip custom pixel parsing — use Sanity's built-in palette metadata

My first version of this pattern fetched a 4×4 thumbnail and tried to read RGB values directly out of the response bytes. That doesn't work: the URL Sanity returns is a compressed JPEG or PNG, not a raw bitmap, so indexing into the buffer like pixels[i * 4] reads compressed, encoded bytes — Huffman-coded DCT coefficients for JPEG, DEFLATE-compressed data for PNG — not pixel colors. You'd need to actually decode the image (with sharp on the server, or canvas in the browser) before any byte in the buffer means anything as a color.

The simpler fix: skip custom decoding entirely. Sanity already computes a dominant-color palette for every uploaded image at upload time, and it costs nothing extra to query:

*[_type == "product"][0] {
  image {
    asset->{
      url,
      metadata {
        palette {
          dominant { background, foreground }
        }
      }
    }
  }
}

palette.dominant.background is a ready-to-use hex color — no fetch, no decode, no build-time computation. This is what I actually ship now.

Rendering the placeholder in Next.js

Render the dominant color as the container's background before the image loads, and fade the real image in on top once it's ready:

// components/SanityImage.tsx
import Image from 'next/image';
import { urlFor } from '@/lib/sanity/imageUrl';
 
interface Props {
  asset: {
    _ref: string;
    metadata?: { palette?: { dominant?: { background: string } } };
  };
  alt: string;
  width: number;
  height: number;
}
 
export function SanityImage({ asset, alt, width, height }: Props) {
  const src = urlFor(asset).width(width).url();
  const bg = asset.metadata?.palette?.dominant?.background ?? '#e5e7eb';
 
  return (
    <div className="relative overflow-hidden" style={{ background: bg }}>
      <Image
        src={src}
        alt={alt}
        width={width}
        height={height}
        className="opacity-0 transition-opacity duration-300 data-[loaded=true]:opacity-100"
        onLoad={(e) => e.currentTarget.setAttribute('data-loaded', 'true')}
      />
    </div>
  );
}

The background style renders immediately — no fetch, no decode, no build step. The next/image loads in parallel and fades in once ready. LCP is the image, not the placeholder, so this pattern doesn't hurt Core Web Vitals scoring.

Overhead and tradeoffs

There's no build-time cost at all with the palette approach — metadata.palette is computed once by Sanity when the asset is uploaded and returned free with any query that projects it. A single dominant color is a coarser approximation than a real blurred thumbnail, but for grids and card thumbnails it's visually indistinguishable from a proper blur-up to most users, and it's an order of magnitude cheaper than either a Base64 LQIP or a custom pixel-parsing pipeline.

For hero images where visual fidelity matters more, project the full palette object (dominant, vibrant, muted, darkMuted, lightVibrant) and pick per context, or fall back to a real metadata.lqip for that specific image rather than every thumbnail in a grid.

When to skip this pattern

If your images are mostly below the fold and you're lazy-loading them with loading="lazy", the browser won't request them until they enter the viewport. In that case, a placeholder costs you nothing because the image fetch is deferred anyway. This pattern pays off when images are in the initial viewport and contribute to LCP.

I also skip it for decorative SVG backgrounds or images where the aspect ratio is enforced by layout (like a 1:1 avatar). In those cases, a solid color or transparent background is simpler and just as fast.

Frequently asked questions

How do I get a dominant color from a Sanity image without decoding it myself?

Project `metadata.palette` in your GROQ query — Sanity computes it automatically at upload time. `palette.dominant.background` gives you a ready-to-use hex color with zero extra fetch or build step, which is simpler and cheaper than trying to derive a color average from a thumbnail image yourself (you'd need to actually decode the compressed JPEG/PNG bytes first, which requires `sharp` or `canvas`, not raw buffer indexing).

Is Sanity's Base64 lqip field expensive to use on a grid of images?

Yes — each `metadata.lqip` string is typically 2-4 kB, so a grid of ten images can add 20-40 kB of inline Base64 to the page HTML before React even hydrates, delaying First Contentful Paint. For grids and thumbnails, `metadata.palette`'s single hex color is a far cheaper placeholder; reserve `lqip` for a hero image or two where the blur fidelity actually matters.