Skip to content

Instantly share code, notes, and snippets.

@haf
Last active February 26, 2025 10:16
Show Gist options
  • Save haf/597e43a639cb333fcd0341d58d136ef7 to your computer and use it in GitHub Desktop.
Save haf/597e43a639cb333fcd0341d58d136ef7 to your computer and use it in GitHub Desktop.
Sanity Image + NextJS / next/image
// url for
export const client = createClient({
projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID,
dataset: process.env.NEXT_PUBLIC_SANITY_DATASET,
apiVersion: process.env.NEXT_PUBLIC_SANITY_API_VERSION
})
const builder = imageUrlBuilder(client)
/**
* Image URL builder
*
* Docs: https://www.sanity.io/docs/presenting-images
* Docs: https://sanity-io-land.slack.com/archives/C07CSTHU8EM/p1734543118558379
*
* @param source - The image source
* @returns The image URL
*/
export const urlFor = (source: SanityImageSource) => {
return builder.image(source)
}
// sanity-image.tsx
import { SanityImageSource } from "@sanity/image-url/lib/types/types"
import { default as NextImage, ImageProps as NextImageProps } from "next/image"
/**
* Docs:
* - https://www.sanity.io/docs/image-url
* - https://github.com/sanity-io/image-url
*/
interface SanityImageBuilderProps {
/** The source image asset reference from Sanity. Required. */
src: SanityImageSource
/**
* The alt text for the image.
* If not provided, the alt text from the Sanity image asset will be used.
*/
alt?: string
/**
* If true, the image will be prioritized for loading by the browser.
* This is useful for images that are critical to the page's content and to avoid
* CLS (Cumulative Layout Shift) issues.
*
* See https://nextjs.org/docs/pages/building-your-application/optimizing/images#priority
*/
priority?: boolean
/** Override the default dataset. Useful when working with multiple datasets */
dataset?: string
/** Override the default project ID. Useful for cross-project asset references */
projectId?: string
// Dimension props
/**
* Specify the desired width in pixels.
* The service will auto-scale the image while maintaining aspect ratio if only one dimension is specified
*/
width?: number
/**
* Specify the desired height in pixels.
* The service will auto-scale the image while maintaining aspect ratio if only one dimension is specified
*/
height?: number
/**
* Set the focal point for image cropping.
* x and y values should be between 0.0 and 1.0, representing relative positions in the image:
* - x: 0 is left edge, 1 is right edge
* - y: 0 is top edge, 1 is bottom edge
*/
focalPoint?: { x: number; y: number }
// Transformation props
/**
* Applies a gaussian blur to the image.
* Value ranges from 0-2000, where:
* - 0 is no blur
* - 50 is subtle blur
* - 2000 is maximum blur
*/
blur?: number
/**
* Sharpen the image.
* Value ranges from 0-100, where:
* - 0 is no sharpening
* - 100 is maximum sharpening
*/
sharpen?: number
/**
* Invert all the colors in the image.
* Useful for creating negative versions of images
*/
invert?: boolean
/**
* Crop the image by specifying pixel coordinates.
* All values are in pixels relative to the original image dimensions:
* - left: distance from left edge
* - top: distance from top edge
* - width: width of crop
* - height: height of crop
*/
rect?: { left: number; top: number; width: number; height: number }
/**
* Convert the image to a specific format.
* - jpg: Good for photos, lossy compression
* - png: Good for graphics, lossless compression
* - webp: Modern format with superior compression
*/
format?: 'jpg' | 'png' | 'webp'
/**
* Enable automatic format selection.
* When set to 'format', the API will serve WebP to browsers that support it
*/
auto?: 'format'
/**
* Rotate the image in 90-degree increments.
* Valid values are 0, 90, 180, or 270 degrees
*/
orientation?: 0 | 90 | 180 | 270
/**
* Set the compression quality.
* Ranges from 0-100, where:
* - 0 is maximum compression (worst quality)
* - 100 is minimum compression (best quality)
* Default is 75 for JPG and WebP
*/
quality?: number
/**
* Mirror the image horizontally and/or vertically.
* - horizontal: flip left-to-right
* - vertical: flip top-to-bottom
*/
flipHorizontal?: boolean
/**
* Mirror the image horizontally and/or vertically.
* - horizontal: flip left-to-right
* - vertical: flip top-to-bottom
*/
flipVertical?: boolean
/**
* Specify the crop mode when using fit=crop.
* - top/bottom/left/right: Crop from edge
* - center: Crop from middle
* - focalpoint: Use focal point data
* - entropy: Use entropy detection
*/
crop?: 'top' | 'bottom' | 'left' | 'right' | 'center' | 'focalpoint' | 'entropy'
/**
* Controls how image is resized to fit specified dimensions:
* - clip: Resize to fit within dimensions
* - crop: Crop to fill dimensions exactly
* - fill: Scale and crop to fill dimensions
* - fillmax: Like fill, but never upscale
* - max: Scale down to fit within dimensions
* - scale: Scale to fit within dimensions
* - min: Scale up or down to fit within dimensions
*/
fit?: 'clip' | 'crop' | 'fill' | 'fillmax' | 'max' | 'scale' | 'min' | null
/**
* Device Pixel Ratio.
* Scale image for high-DPI displays:
* - 1: Standard resolution
* - 2: Retina/HiDPI
* - 3: High-end displays
*/
dpr?: number
/**
* Adjust image saturation.
* Ranges from -100 to 100:
* - -100: Grayscale
* - 0: Normal saturation
* - 100: Maximum saturation
*/
saturation?: number
/**
* Ignore any pre-defined image parameters.
* Useful when you want to override defaults set in Sanity
*/
ignoreImageParams?: boolean
/**
* Add padding around the image.
* Specified in pixels, applies to all sides
*/
pad?: number
/** Custom name for the image asset in the URL */
vanityName?: string
/**
* Specify frame for animated images.
* Currently only supports value of 1
*/
frame?: number
/** Background color to use when padding images */
bg?: string
// DEPRECATED:
// /** Maximum allowed width in pixels */
// maxWidth?: number
// /** Maximum allowed height in pixels */
// maxHeight?: number
// /** Minimum allowed width in pixels */
// minWidth?: number
// /** Minimum allowed height in pixels */
// minHeight?: number
}
// Omit width and height from NextImageProps as they're already in SanityImageBuilderProps
export type SanityImageProps = SanityImageBuilderProps & Omit<NextImageProps, 'src' | 'width' | 'height' | 'priority' | 'alt'>
function getDimensionsFromUrl(url: string): { width?: number; height?: number } {
const params = new URLSearchParams(url.split('?')[1] || '')
// Get width and height from URL params (w= and h=)
const width = params.get('w') ? parseInt(params.get('w')!) : undefined
const height = params.get('h') ? parseInt(params.get('h')!) : undefined
// If no w/h params, try to get from filename pattern: asset-WxH.format
if (!width || !height) {
const matches = url.match(/-(\d+)x(\d+)\./)
if (matches) {
return {
width: parseInt(matches[1]),
height: parseInt(matches[2])
}
}
}
return { width, height }
}
const Image = ({
src,
fill,
dataset,
projectId,
width,
height,
focalPoint,
blur,
sharpen,
invert,
rect,
format,
auto,
orientation,
quality,
flipHorizontal,
flipVertical,
crop,
fit = 'max',
dpr,
saturation,
ignoreImageParams,
pad,
vanityName,
frame,
...nextImageProps
}: SanityImageProps) => {
if (!src) {
return null
}
let builder = urlFor(src)
// Apply all Sanity image transformations if they exist
if (dataset) builder = builder.dataset(dataset)
if (projectId) builder = builder.projectId(projectId)
if (width) builder = builder.width(Number(width.toFixed(0)))
if (height) builder = builder.height(Number(height.toFixed(0)))
if (focalPoint) builder = builder.focalPoint(focalPoint.x, focalPoint.y)
if (blur) builder = builder.blur(blur)
if (sharpen) builder = builder.sharpen(sharpen)
if (invert) builder = builder.invert(true)
if (rect) builder = builder.rect(rect.left, rect.top, rect.width, rect.height)
if (format) builder = builder.format(format)
if (auto) builder = builder.auto(auto)
if (orientation) builder = builder.orientation(orientation)
if (quality) builder = builder.quality(quality)
if (flipHorizontal) builder = builder.flipHorizontal()
if (flipVertical) builder = builder.flipVertical()
if (crop) builder = builder.crop(crop)
if (fit) builder = builder.fit(fit)
if (dpr) builder = builder.dpr(dpr)
if (saturation) builder = builder.saturation(saturation)
if (ignoreImageParams) builder = builder.ignoreImageParams()
if (pad) builder = builder.pad(pad)
if (vanityName) builder = builder.vanityName(vanityName)
if (frame) builder = builder.frame(frame)
let altText
// Use alt text from Sanity if not provided explicitly, and set placeholder if LQIP is available
if (typeof src === 'object') {
if ('alt' in src && src.alt) {
altText = src.alt
} else if ('asset' in src && src.asset) {
altText = src.asset.alt
if ('metadata' in src.asset && src.asset.metadata && nextImageProps.blurDataURL == null) {
nextImageProps = {
...nextImageProps,
placeholder: 'blur',
blurDataURL: src.asset.metadata.lqip
}
}
}
}
// Get the final URL
const url = builder.url()
const alt = nextImageProps.alt ?? altText ?? 'Missing alt text'
// If fill is true, use the fill prop and don't query for w/h
if (fill) {
return (
<NextImage
src={url}
{...nextImageProps}
alt={alt}
fill
/>
)
}
// otherwise, extract the dimensions from the URL
const { width: finalWidth, height: finalHeight } = getDimensionsFromUrl(url)
return (
<NextImage
src={url}
alt={alt}
width={finalWidth}
height={finalHeight}
{...nextImageProps}
/>
)
}
export default Image
@coreyward
Copy link

coreyward commented Dec 27, 2024

For what it's worth, sanity-image works perfectly with Next.js without the extra trip through the Vercel servers. It also handles hotspot and crop parameters, generates srcset attributes, and works around bugs in @sanity/image-url and the Sanity image API.

@haf
Copy link
Author

haf commented Feb 26, 2025

I reviewed sanity-image thoroughly before I wrote the above, and it didn't support my use case of being a drop-in replacement for next/image, but I see that it's been improved since then, so good job! :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment