
How to Handle Images in Sanity
To handle images in Sanity, you add a field with type: 'image' to your schema, upload files in Sanity Studio, and query them with GROQ. Each image field stores a reference to an image asset document plus context such as crop, hotspot, alt text and caption. On the front end, you build image URLs with @sanity/image-url, which turns that reference into a cdn.sanity.io URL with resizing and format parameters, and render it with a normal <img> or the Next.js Image component. Sanity's image pipeline resizes, crops and converts images on demand, so you upload one original and request whatever size you need.
Images are one of the first things you'll work with in Sanity, and the model is slightly different from a traditional CMS where an upload creates a few fixed sizes. Understanding the difference between the image field and the asset it points to makes everything else, from cropping to performance, easier. This guide walks through the full workflow: defining image fields, how images are stored, what metadata you get, querying with GROQ, rendering in Next.js, adding images to rich text and uploading programmatically.
How Sanity Stores Images
There are two separate pieces of data behind every image:
- The image field: An object in your document with
_type: 'image'. It holds anassetreference, optionalcropandhotspotvalues, and any extra fields you define, such asaltorcaption. - The image asset: A separate document with
_type: 'sanity.imageAsset'. It holds the file's URL, original filename, size, MIME type and generated metadata such as dimensions and a colour palette.
A stored image field looks like this:
{
"mainImage": {
"_type": "image",
"asset": {
"_type": "reference",
"_ref": "image-7f3c9a1e5b2d4f6a8c0e1b3d5f7a9c2e4b6d8f0a-1600x900-jpg"
},
"alt": "A cyclist riding along a coastal road at sunset"
}
}
This split is deliberate. One asset can be used in many documents, each with its own crop, hotspot and caption, without uploading the file twice. Sanity also identifies assets by a hash of their contents, so uploading the same file under two different names creates a single asset.
The asset ID encodes useful information: the content hash, the original dimensions (1600x900) and the file extension (jpg). Assets are stored in the Content Lake alongside your documents and served from Sanity's asset CDN.
Add an Image Field to Your Schema
The simplest image field needs just a name and a type:
// schemaTypes/post.ts
import { defineField, defineType } from "sanity";
export const post = defineType({
name: "post",
title: "Post",
type: "document",
fields: [
defineField({ name: "title", type: "string" }),
defineField({
name: "mainImage",
title: "Main image",
type: "image",
options: {
hotspot: true,
},
fields: [
defineField({
name: "alt",
title: "Alternative text",
type: "string",
description:
"Describe the image for screen readers and search engines.",
}),
defineField({
name: "caption",
type: "string",
}),
],
}),
],
});
Two parts are worth calling out:
options.hotspot: Turns on the crop and hotspot tools in Studio, so editors can mark the important part of the image. It'sfalseby default. The full mechanics are in hotspot and crop in Sanity.fields: Adds your own fields to the image object. Alt text and captions belong here because they describe how the image is used in this document, not the file itself.
Useful Image Options
| Option | Default | What it does |
|---|---|---|
hotspot | false | Enables crop and hotspot editing. Accepts true or an object with previews |
metadata | blurhash, thumbhash, lqip, palette | Which metadata to extract on upload |
accept | Any image | Restricts uploads by MIME type, for example 'image/png, image/webp' |
storeOriginalFilename | true | Saves the uploaded filename on the asset |
sources | All configured sources | Limits which asset sources the field offers |
disableNew | Not set | Hides upload so editors can only pick existing assets |
Set storeOriginalFilename: false if file names might reveal something sensitive, such as an unreleased product name.
Validating Image Fields
Use required() with assetRequired() to make sure an actual file has been uploaded, not just an empty image object with a caption:
defineField({
name: "mainImage",
type: "image",
validation: (rule) => rule.required().assetRequired(),
});
For rules on nested fields such as mandatory alt text, see alt text requirements for Sanity images.
Make a Reusable Image Type
If you use the same image setup in several places, define it once as a named type and reuse it:
// schemaTypes/objects/accessibleImage.ts
import { defineField, defineType } from "sanity";
export const accessibleImage = defineType({
name: "accessibleImage",
title: "Image",
type: "image",
options: { hotspot: true },
fields: [
defineField({ name: "alt", title: "Alternative text", type: "string" }),
defineField({ name: "caption", type: "string" }),
],
});
Register it in your schema types, then use type: 'accessibleImage' anywhere: a post's main image, an author photo, or an image block in rich text. One definition means consistent fields and validation across your project. It's the same idea as any other reusable type in your schema.
What Metadata You Get
When an image is uploaded, Sanity analyses it and stores metadata on the asset document:
- Always included:
dimensions(width, height and aspect ratio),hasAlphaandisOpaque. - Included by default:
lqip(a tiny base64 placeholder),blurHash,thumbHashandpalette(dominant and vibrant colours with contrasting foregrounds). - Off by default:
exif(camera data),location(GPS coordinates) andimage.
Location and camera data are off by default for privacy. If you turn them on, they're extracted at upload time, and changing the schema later doesn't remove data already stored.
defineField({
name: "photo",
type: "image",
options: {
metadata: ["lqip", "palette", "exif"],
},
});
Be careful when you specify the metadata array. Anything you leave out isn't generated, so the example above drops blurhash and thumbhash. Some metadata is also computed asynchronously, so a query straight after upload might briefly return empty values.
Query Images With GROQ
Because the image field holds a reference, you need to dereference the asset to get its URL or metadata. The -> operator follows the reference:
*[_type == "post" && slug.current == $slug][0]{
title,
mainImage{
alt,
caption,
hotspot,
crop,
asset->{
_id,
url,
metadata {
lqip,
dimensions { width, height, aspectRatio }
}
}
}
}
The result includes everything you need to render a responsive, well-placed image:
{
"title": "Riding the Coast Road",
"mainImage": {
"alt": "A cyclist riding along a coastal road at sunset",
"caption": null,
"hotspot": { "x": 0.62, "y": 0.41, "width": 0.3, "height": 0.4 },
"crop": { "top": 0, "bottom": 0, "left": 0, "right": 0 },
"asset": {
"_id": "image-7f3c9a1e5b2d4f6a8c0e1b3d5f7a9c2e4b6d8f0a-1600x900-jpg",
"url": "https://cdn.sanity.io/images/abc123/production/7f3c9a1e5b2d4f6a8c0e1b3d5f7a9c2e4b6d8f0a-1600x900.jpg",
"metadata": {
"lqip": "data:image/jpeg;base64,/9j/2wBDAAYEBQYFBAYGBQYHBwYIChAKCgkJChQODw...",
"dimensions": {
"width": 1600,
"height": 900,
"aspectRatio": 1.7777777777777777
}
}
}
}
}
Keep crop and hotspot in the projection if you plan to use the URL builder, because it needs them to respect the editor's choices. If you need a refresher on following references, read how to dereference documents in GROQ.
Render Images in Next.js
Avoid using asset->url directly in an <img> tag. That URL points to the full-size original, which can be several megabytes and, as Sanity's docs warn, can push up your bandwidth usage. Build a transformed URL instead.
Set Up the URL Builder
Install the package and create a helper:
npm install @sanity/image-url
// src/sanity/lib/image.ts
import {
createImageUrlBuilder,
type SanityImageSource,
} from "@sanity/image-url";
import { client } from "./client";
const builder = createImageUrlBuilder(client);
export function urlFor(source: SanityImageSource) {
return builder.image(source);
}
Version 2 of @sanity/image-url uses the named createImageUrlBuilder export. Older tutorials use a default imageUrlBuilder import, which you'll need to update. The image URL builder guide covers every method in detail.
Render With next/image
Add Sanity's CDN to your Next.js config so next/image accepts remote URLs:
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
images: {
remotePatterns: [new URL("https://cdn.sanity.io/**")],
},
};
export default nextConfig;
Then render the image in a server component:
// src/components/post-hero.tsx
import Image from "next/image";
import { urlFor } from "@/sanity/lib/image";
type PostHeroProps = {
image: {
alt?: string;
asset: {
metadata: { lqip: string; dimensions: { width: number; height: number } };
};
};
};
export function PostHero({ image }: PostHeroProps) {
return (
<Image
src={urlFor(image).width(1600).height(900).auto("format").url()}
alt={image.alt ?? ""}
width={1600}
height={900}
placeholder="blur"
blurDataURL={image.asset.metadata.lqip}
sizes="(max-width: 768px) 100vw, 1200px"
className="h-auto w-full rounded-xl"
preload
/>
);
}
The lqip value works directly as blurDataURL, so readers see a soft preview while the full image loads.
Or Use next-sanity/image
next-sanity also exports an Image component from next-sanity/image that wraps next/image with a loader pointing straight at Sanity's CDN. It adds auto=format and sensible fit values for each srcSet width, and it doesn't need remotePatterns. Sanity's docs mark it as alpha, so check the current status before relying on it in production. Performance techniques like this are covered in more depth in optimising Sanity images for performance.
Images Inside Portable Text
To let editors drop images into rich text, add an image member to the block array:
// schemaTypes/blockContent.ts
import { defineArrayMember, defineType } from "sanity";
export const blockContent = defineType({
name: "blockContent",
title: "Block content",
type: "array",
of: [
defineArrayMember({ type: "block" }),
defineArrayMember({ type: "accessibleImage" }),
],
});
Then render it with a custom types component in @portabletext/react:
import Image from "next/image";
import type { PortableTextComponents } from "@portabletext/react";
import { getImageDimensions } from "@sanity/asset-utils";
import { urlFor } from "@/sanity/lib/image";
export const components: PortableTextComponents = {
types: {
accessibleImage: ({ value }) => {
if (!value?.asset?._ref) return null;
const { width, height } = getImageDimensions(value);
return (
<figure>
<Image
src={urlFor(value).width(1200).fit("max").auto("format").url()}
alt={value.alt ?? ""}
width={Math.min(width, 1200)}
height={Math.round((Math.min(width, 1200) / width) * height)}
/>
{value.caption && <figcaption>{value.caption}</figcaption>}
</figure>
);
},
},
};
getImageDimensions from @sanity/asset-utils reads the width and height from the asset ID, so you don't need to dereference the asset in your query just to get the aspect ratio.
Upload Images Programmatically
For migrations or scripts, upload with @sanity/client and a token with write access:
// scripts/upload-image.ts
import { createClient } from "@sanity/client";
import { createReadStream } from "node:fs";
import { basename } from "node:path";
const client = createClient({
projectId: process.env.SANITY_PROJECT_ID!,
dataset: "production",
apiVersion: "2026-03-01",
useCdn: false,
token: process.env.SANITY_API_TOKEN,
});
const filePath = "./images/hero.jpg";
const asset = await client.assets.upload("image", createReadStream(filePath), {
filename: basename(filePath),
});
await client
.patch("post-123")
.set({
mainImage: {
_type: "image",
asset: { _type: "reference", _ref: asset._id },
alt: "A cyclist riding along a coastal road at sunset",
},
})
.commit();
Never put a write token in browser code. In the browser, uploads should go through Studio or an authenticated user session.
Images vs Files
Use type: 'image' for anything you want to display, resize or crop. Use type: 'file' for PDFs, audio, archives and other downloads. If you upload a photo as a file, you lose metadata and the image pipeline. The differences, and how to handle file downloads, are covered in managing files and assets in Sanity.
FAQ: Images in Sanity
Images are stored as asset documents in your project's dataset and served from Sanity's asset CDN at cdn.sanity.io. Your documents hold a reference to the asset, along with crop, hotspot and any custom fields such as alt text.
No. Sanity stores the original and generates sizes on demand when you request a URL with parameters such as w, h, fit and auto=format. Transformed images are cached on the CDN, so repeated requests are fast.
The image field only contains a reference. Dereference it with asset->url in your GROQ projection, or pass the whole image object to @sanity/image-url, which builds the URL from the reference.
Yes. Assets stored in a dataset are publicly reachable by URL, even if the dataset is private. The URL contains a content hash, so it's hard to guess, but anyone with the link can view it. Per-asset privacy is a Media Library feature.
Yes. Select the existing asset from the image field's browse option. Each document gets its own image object with its own crop, hotspot and caption, all pointing to the same asset.
Common formats including JPG, PNG, WebP, GIF, SVG, TIFF, HEIF and AVIF are supported. You can restrict uploads on a field with the accept option, for example to PNG and WebP only.
Conclusion
Handling images in Sanity starts with one idea: the image field is context, and the asset is the file. Define image fields with hotspot and custom fields such as alt, dereference the asset in GROQ when you need its URL or metadata, and build URLs with @sanity/image-url rather than linking to the original. The lqip and dimensions metadata give you placeholders and aspect ratios for free.
Once that foundation is in place, the rest is refinement. Add hotspot and crop so images look right at any size, tune formats and sizes for performance, make alt text mandatory, and reuse a single image type throughout your schema so every image on your site behaves the same way.


