DEV Community

Cover image for Image Optimization with Next.js and Sanity.io
Drazen Bebic
Drazen Bebic Subscriber

Posted on • Originally published at bebic.dev on

Image Optimization with Next.js and Sanity.io

I was putting the finishing touches on my blog, deployed it to Vercel, and ran straight to Lighthouse to reap the fruits of my hard work. I didn't actually reap the fruits I expected, the performance score was below 80. Why? Because of unoptimized images.

Picking Sanity.io for the backend of my headless blog was a good call. It was easy to set up and get the page running. Initially I just fetched the URL's from Sanity and put them in the next/image component. Done. Except that it didn't work so well and the images were quite often way too large for the container they were shown in.

I sat down and figured it out. Here's the complete workflow I use for optimizing my images in a Next.js frontend which utilizes a Sanity.io backend.

Introduction

This guide will create 4 components which we will use across the application. Here's a run-down of them.

New Image Component

We will create a new SanityImage React component. This component wraps around the next/image component and will be your new go-to component for your images coming from Sanity.

Custom Image Loader

We'll also create a sanityLoader image loader for the next/image component. This will be used in the SanityImage component.

Utility Functions

The urlFor utility function which will be used by the sanityLoader to adjust the image before handing it over to the next/image component.

The urlForImage component used across your application to convert Sanity Image objects to URLs for the SanityImage React component.

Prerequisites

This guide will not show you how to set up Sanity.io with Next.js. I will assume that the initial setup is done. You will need to update your next config, check your Sanity schema for images, and update your GROQ queries.

Configuring Next.js

We will also need to configure the next config file a bit. We need to make 3 changes:

  • Prefer the avif format over webp because of its superior compression.
  • Support lower image quality to save bandwidth (75 is default).
  • Add the Sanity remote patterns so the images can be shown.
/** @type {import('next').NextConfig} */
const nextConfig = {
  images: {
    // Prioritize AVIF, then WebP. Browsers that support AVIF will get it.
    formats: ['image/avif', 'image/webp'],
    // Allow for lower quality images to save bandwidth (default is 75)
    qualities: [60, 75],
    // Since we're using Sanity, remember to add the hostname
    remotePatterns: [
      {
        protocol: 'https',
        hostname: 'cdn.sanity.io',
      },
    ],
  },
};

export default nextConfig;
Enter fullscreen mode Exit fullscreen mode

Defining the Sanity Schema

The next thing we need to do is to define a proper image schema inside Sanity, so that we get back all the fields we need. You probably have something similar set up, but make sure you have everything covered.

import { defineField } from 'sanity'

export default {
  // ... rest of the schema
  fields: [
    defineField({
      name: 'coverImage',
      title: 'Cover Image',
      type: 'image',
      description: 'This image will be used for the blog post cover and SEO cards.',
      options: {
        hotspot: true, 
        metadata: ['lqip', 'palette'], 
      },
      fields: [
        defineField({
          name: 'alt',
          type: 'string',
          title: 'Alternative Text',
          description: 'Important for SEO and accessibility.',
          validation: (rule) => rule.required().warning('Please provide alt text.'),
        }),
      ],
    }),
  ]
}
Enter fullscreen mode Exit fullscreen mode

Updating the GROQ Query

The last thing we need to prepare is a reusable GROQ snippet for images, so that you don't have to copy-paste the same block multiple times in your GROQ queries.

// A reusable GROQ snippet for images
const imageFields = groq`
  asset->{
    _id,
    url,
    metadata {
      lqip, // The base64 placeholder string
      dimensions {
        width,
        height,
        aspectRatio
      }
    }
  },
  alt,
  hotspot,
  crop
`;

// Using it in your main query
export const POST_QUERY = groq`
  *[_type == "post" && slug.current == $slug][0] {
    _id,
    title,
    // Apply the projection to your image field
    coverImage {
      ${imageFields}
    },
    // ... rest of your fields
  }
`;
Enter fullscreen mode Exit fullscreen mode

Step 1: The Builder Utilities

Now that we're prepared, let's start by creating the utility functions I mentioned earlier. Create a sanity/lib/image.ts file in the root directory of your Next.js application and copy-paste this code into it:

import { createImageUrlBuilder, SanityImageSource } from '@sanity/image-url';
import type { Image } from 'sanity';

import { dataset, projectId } from '../env'; // Your env configuration

const imageBuilder = createImageUrlBuilder({
  projectId: projectId || '',
  dataset: dataset || '',
});

// 1. Returns the builder. Used by our sanityLoader (Step 2)
export const urlFor = (source: SanityImageSource) =>
  imageBuilder.image(source);

// 2. Returns a URL string. Also used for Metadata/OG Images where we can't use next/image
export const urlForImage = (image: Image, width?: number) => {
  const builder = urlFor(image);

  if (width) {
    return builder.width(width).url();
  }

  return builder.url();
};
Enter fullscreen mode Exit fullscreen mode

Step 2: The Sanity Loader

Next.js already has a great Image Optimization API, but our images are hosted on Sanity's CDN and we don't want Next.js to re-process them. Sanity's own CDN is pretty great too, it can crop, resize and convert images on the fly.

This loader will basically be a bridge between Sanity and Next.js. Basically it's telling Next.js: "When you need a 400px wide version of this image, this is how you ask Sanity for it."

Create a file called sanityLoader.ts. By using our urlFor utility, we're making sure that any hotspots or crops defined in the Studio are automatically respected.

'use client';

import { ImageLoaderProps } from 'next/image';
import { urlFor } from '@/sanity/lib/image';

export default function sanityLoader({ src, width, quality }: ImageLoaderProps) {
  return urlFor(src)
    .width(width)
    .quality(quality || 75)
    .auto('format') // Serves AVIF or WebP based on browser support
    .fit('max')
    .url();
}
Enter fullscreen mode Exit fullscreen mode

Step 3: The Wrapper Component

Repeating code is bad. Writing loader={sanityLoader} everywhere is just a pain. Instead, we'll create a reusable <SanityImage /> wrapper component.

This React component will handle the very basic boilerplate. It will assign the loader and pass all other props directly to the next/image component with the exception of the loader prop.

'use client';

import { FC } from 'react';
import Image, { ImageProps } from 'next/image';

import sanityLoader from '@/utils/sanity-loader';

export type SanityImageProps = Omit<ImageProps, 'loader'>;

export const SanityImage: FC<SanityImageProps> = props => {
  // Already included in ImageProps
  // eslint-disable-next-line jsx-a11y/alt-text
  return <Image quality={60} {...props} loader={sanityLoader} />;
};
Enter fullscreen mode Exit fullscreen mode

I also had to disable an ESLint rule to keep it happy, but that's all there is to it.

Step 4: The sizes Property

The final piece of the puzzle. This is the part I understood the least so I had to look it up a bit, but essentially it boils down to displaying different image sizes for different screen sizes.

Let's imagine you're displaying a 3-column grid on desktop. The image is only 300px wide, if you don't define sizes the browser will think that it needs an image which fits the whole screen. This way you'll possibly download a 4K and display it in a 300px container. This will absolutely annihilate your LCP score.

You need to tell the browser exactly how much space the image occupies at different breakpoints. Here's a real-world example:

The Layout:

  • Mobile (< 768px): 1 column (Image is wide, ~90vw)
  • Tablet (< 1200px): 2 columns (Image is ~45vw)
  • Desktop (>= 1200px): 3 columns (Image is ~30vw)

The Code:

import { urlForImage } from '@/sanity/lib/image';

const coverImageUrl = urlForImage(post.coverImage);

<SanityImage
  src={coverImageUrl}
  className="object-cover"
  // The crucial part for performance:
  sizes="(max-width: 768px) 90vw, (max-width: 1200px) 45vw, 30vw"
/>
Enter fullscreen mode Exit fullscreen mode

Translation:

  • Is the screen smaller than 768px? → Download the 90vw variant.
  • Is it smaller than 1200px? → Download the 45vw variant.
  • Larger? → Download the 30vw variant.

By implementing this, I saw my network payload for images drop by over 60%.

Conclusion

While it seems like it at first, optimizing images in a Headless CMS environment doesn't have to be a black box. Enable AVIF in Next.js, implement a loader that makes sense for your CMS, and you can achieve near-instant load times.

Next.js and Sanity are a great couple. They give you the tools to score that 100 on Lighthouse, you just have to wire them together and you're golden.

Top comments (0)