Last active
February 26, 2025 10:16
-
-
Save haf/597e43a639cb333fcd0341d58d136ef7 to your computer and use it in GitHub Desktop.
Sanity Image + NextJS / next/image
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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 |
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
For what it's worth, sanity-image works perfectly with Next.js without the extra trip through the Vercel servers. It also handles
hotspot
andcrop
parameters, generatessrcset
attributes, and works around bugs in@sanity/image-url
and the Sanity image API.