Nextjs Image Component: In-Depth Tutorial

The Next.js Image component does a lot under the hood, but how does it work and how do you use it effectively?

Last UpdatedMarch 29, 2024
Estimated Read Time19 minutes

Prerequisites

In this guide, I am assuming that you are comfortable with Next.js and at least have basic experience with the Next.js Image component.

My goal with this post is to explain how the Image component works and ways to use it more effectively.

What we will cover

I will be talking about:

  • What the Next.js Image component does
  • How the Next.js Image component works
  • What is an image "loader"?
  • When to use a custom image loader
  • The basics of image sizing
  • How to use the Image component with remote (external) images
  • How to use the Image component with MDX

I will NOT be covering:

  • Basic usage of the Next.js Image component (docs here)

Why images are hard to deal with

As web developers, we deal with images daily. Images are the lifeblood of the entire internet, yet they are oftentimes the most troublesome elements on a webpage. Just think of all the image problems:

  • Images showing up with the wrong aspect ratio
  • Images not responsively resizing with the viewport
  • Images loading slowly, or even worse, loading slowly and blocking the webpage from showing anything to the user

At the crux of the problem is one simple fact. Images are the largest individual elements on a webpage (in general). Sure, JavaScript can cause issues, but that's usually a combination of factors. With images, a single 5000 x 2000 pixel image that occupies 3.5MB of space can wreak havoc on your user's experience and cause your Core Web Vitals to be so poor that Google penalizes your website (bad SEO).

Why does this matter?

Because if you don't get close to the following core web vitals for your site/app, Google will find someone else for that #1 search result.

  • Largest Contentful Paint (LCP) - 2.5 seconds
  • First Input Delay (FID) - 100 milliseconds
  • Cumulative Layout Shift (CLS) - <= 0.1

So... If a single image can kill your core web vitals, and your core web vitals are your ticket to the top ranking result on Google, shouldn't we take some time to learn about using images correctly on the web?

You'd think so, but if you are anything like I was, you've brushed this harsh reality off for years and built web apps that could use some serious improvement in the performance department. In this post, my goal is to give you an understanding of:

  1. What Next.js + Vercel does for us with images
  2. Why it matters
  3. How to integrate images into your web apps correctly (and improve core web vitals)

What does the Next.js Image component do?

Fortunately and unfortunately, Next.js Image (more specifically, the Next.js Image API + <Image />) is doing a lot of things under the hood for us. This is great because it abstracts away common operations such as image compression, resizing, lazy loading, and CDN delivery, but it leaves a lot of room for misuse of the component.

My former self and many tutorials on the internet use this Image component in ways that were probably not intended by the original creators of it. I think this happens for two reasons:

  1. The Vercel documentation of the <Image /> component and the Image Optimization API is good but assumes a baseline understanding of how image optimization works (which not all devs think about daily or even have)
  2. Images are complicated

To understand this, we need to break down what Vercel (the company) is doing and what Image (an open-source React component) is doing.

Image Component responsibilities

The Image component is responsible for three main things:

  1. Rendering your image to the browser according to the specified attributes (e.g. the same thing the native img HTML element does)
  2. Generating a list of image sizes and choosing the correct one dynamically based on the current browser size
  3. Lazy loading - only load when the image is scrolled into view

The Image component DOES NOT do the following:

  • Alter the original image
  • Generate new images

Remember, a component needs an external API to do things outside of regular HTML functionality. While the component can generate a list of different image sizes (the srcset), it cannot actually generate all of those images for display nor can it optimize the image.

That's where Vercel comes in.

Vercel image responsibilities

So you've written your <Image /> component and that component has generated a list of image sizes and selected the appropriate size for your current browser viewport width. But we're missing a step here. How are these different image sizes created? Where do they come from?

All of this is handled at runtime by the Vercel platform via the "Image Optimization API".

When we talk about the Image Optimization API, we are talking about the service that Vercel (the company) is offering to you when you deploy your Next.js app to their platform. This service is comparable to other image optimization SaaS products such as Cloudinary, ImageKit or Imgix. Vercel's image optimization API is solely focused on the optimization piece of things and does not offer transformations (e.g. cropping, watermarks, AI recognition, etc.) of images like the other services do. Vercel is quickly growing though so by the time you read this, their offering may have changed!

How Vercel works together with the Image component

Let's bring things together with an example.

<Image src="/some-large-image-url.png" width={5000} height={3500} />

In the code above, you've hooked up your Image component, and let's say you deploy your Next.js app to Vercel. When a user visits the page which this image is on, here's generally what happens:

  1. The Image component generates a list of image sizes for various devices and stores in the srcset attribute on the native img HTML element
  2. The Image component checks to see if the image is in the current viewport. If it is, it loads the image. If not, it waits.
  3. The Image component checks the browser viewport width and dynamically selects the best image size from the srcset (generated in step 1)
  4. The Image component invokes the default "loader", which then reaches out to the Vercel Image Optimization API
  5. Vercel runs some backend code that generates an image of the selected size, compresses it to its smallest size, and converts it to webp (usually) as this is a more efficient image format for the web.
  6. Vercel takes this new image, "tags" it (via URL or image hash), and stores that tag in its cache
  7. Vercel serves this newly created image over its Edge network (CDN)

Now, let's say you refresh the page. Here's what happens:

  1. Steps 1-4 happen above
  2. Vercel finds the image in its cache and serves via CDN without doing anything else

What is an image "loader"?

In the explanation above, I mentioned that the Image component invokes the "default loader". But what does this mean?

Next.js Image uses the loader prop to specify how you plan on retrieving all those images that the Image component generated in the srcset. If your original image is 5000 x 3500 and the Image component generates a 500 x 350 image for a smaller screen, who is going to generate that smaller image?

By default, Next.js Image uses the "default loader", which basically means, "reach out to the Vercel image optimization service to generate all the images".

One of the great things about Next.js is that you can change this configuration and use whatever "loader" you want. For example, let's say that you are a long-time customer of Cloudinary and therefore, don't need Vercel's image optimization service. All you have to do is update your next.config.js file (we can do this because Cloudinary is one of the built-in loaders):

{
  images: {
    loader: 'cloudinary'
  }
}

This will disable the Vercel image optimization API (so you won't be billed) and uses Cloudinary instead. But what if Vercel doesn't support your provider? For example, what if you wanted to use ImageKit with the Image component. To do this, you can write your own loader and pass it as a prop to the Image component. This will have the same effect as our changes with Cloudinary above.

// Sourced from - https://docs.imagekit.io/getting-started/quickstart-guides/nextjs
const customImageKitLoader = ({ src, width, quality }) => {
  if (src[0] === "/") src = src.slice(1);
  const params = [`w-${width}`];
  if (quality) {
    params.push(`q-${quality}`);
  }
  const paramsString = params.join(",");
  var urlEndpoint = "https://ik.imagekit.io/your_imagekit_id";
  if (urlEndpoint[urlEndpoint.length - 1] === "/")
    urlEndpoint = urlEndpoint.substring(0, urlEndpoint.length - 1);
  return `${urlEndpoint}/${src}?tr=${paramsString}`;
};

<Image
  loader={customImageKitLoader}
  src="/some-large-image-url.png"
  width={5000}
  height={3500}
/>;

What is a "source" or "origin" image? (and pricing)

Note: I will be using the term "origin" here onward, but this term is interchangeable with "source".

A related concept to "loaders" is what we call an origin image. Some image optimization APIs like Vercel and Imgix price their product based on how many origin images were served during the billing period. Both Vercel and Imgix have a free tier that allows up to 1,000 origin images per month.

An "origin" describes where your images are stored. You can store images in a database (wouldn't recommend), as part of your build assets (good for low quantities), or my favorite, in object storage such as AWS S3 buckets.

When you pass a path or URL to the src prop of Next.js Image, you are specifying the origin of that image. To better explain how this is priced, let's walk through an example. Let's say that you store your images on S3.

  • 2000 total images stored on S3
  • 800 images were accessed (and optimized) during the billing period (by Vercel)

In this scenario, you would be billed based on 800 origin images. In other words, pricing is usage based.

Other services may price based on total image optimizations/transformations or bandwidth.

On the modern web with cloud image optimization services, the goal is to have a single, high-resolution copy of an image. In the past, you may have stored 10 versions of the same image on S3, but today, it is more practical to store the largest version of your image on S3 and utilize an optimization API like Vercel, Imgix, Cloudinary, ImageKit, etc. to then generate the variations you need. This is more expensive, but way less work for you as the maintainer of all those images!

When should you use a custom image loader?

Remember, the responsibility of an image optimization API is to:

  1. Generate different sizes of an origin image
  2. Compress images
  3. Serve the appropriate image format
  4. Serve images over a CDN
  5. Transform images on the fly

Vercel does 1-4, but not 5. In other words, there are three main reasons you might want a custom loader if your Next.js app is deployed to Vercel:

  1. You require image transformations
  2. You don't like Vercel's pricing model
  3. You are already locked in with another service like Cloudinary

Incorrect usage of an image loader

Let's say you pay for Cloudinary's service and use Next.js. Let's also assume that you HAVE NOT updated your next.config.js to use a custom loader. Here is how NOT to use Next.js Image:

<Image
  src="https://res.cloudinary.com/demo/image/c_scale,w_100/my-image.jpg"
  width={100}
  height={80}
/>

There are many problems with this.

First, by NOT defining a custom loader in next.config.js, you are by default still using Vercel's image optimization API. In other words, you're paying for Cloudinary AND Vercel at the same time to do the same thing.

Secondly, you're defining a w_100 in your Cloudinary url, which means you're serving a 100px wide image, which will then be downloaded and used as the "origin" image by Vercel, which will try to make it bigger for larger screens and pixelate it.

To make this example less bad, you should be placing an unoptimized prop on the component to tell Vercel leave it as-is. This will result in a picture that is always 100px wide (all screens) which isn't ideal.

<Image
  src="https://res.cloudinary.com/demo/image/c_scale,w_100/my-image.jpg"
  width={100}
  height={80}
  unoptimized // this disables Vercel optimization
/>

Correct usage of an image loader

Let's say you want to use Cloudinary. First, enable in next.config.js

const nextConfig = {
  images: {
    loader: "cloudinary",
    path: "https://res.cloudinary.com/your-unique-account-id/",
  },
};

Now, you can pass relative image paths:

<Image
  src='my-image-name.png'
  width={500}
  height={400}
/>

Internally, Next.js will take the props from your component and translate them to a URL that Cloudinary understands via the built-in Cloudinary loader.

If you wanted the ability to pass additional transformations to the src prop, you could extend the built-in Cloudinary loader with your own custom implementation.

Can I use Cloudfront with Next.js Image?

Since Cloudfront is a common CDN, I'll go ahead and cover how this might be used with Next.js image. With Cloudfront, you really have two options:

  1. Use Vercel's image optimization API
  2. Write some AWS Lambda functions to optimize your images

Cloudfront is a CDN, but unlike services like Cloudinary and Imgix, it does NOT optimize images. It simply serves them fast (over the CDN). Because of this, it is not advantageous to use Cloudfront as a loader unless you go with option 2 and implement your own custom Lambda functions that process your photos. This is difficult and unnecessary for most people when there are services like Vercel and Cloudinary that already do this.

While it is a bit redundant (since Vercel serves images over a CDN and Cloudfront is a CDN), here's how you'd want to use Cloudfront with Next.js Image:

<Image src="https://mycustomcloudfrontdomain.com" width={500} height={300} />

By doing this, we're treating Cloudfront as our "origin" that Vercel will download, optimize, and resize images from.

Defining the width and height of remote images

In the prior section, I showed an example of us using a Cloudinary loader with Next.js Image. Did you notice any limitations that we might have with that default usage?

With Next.js Image, we have a catch-22 going on with remote images (i.e. images retrieved from a CDN like AWS Cloudfront, Cloudinary, etc.).

The Next.js Image component requires us to define a height and a width so it can put a placeholder space in the DOM before fetching the image (to avoid CLS). That's a great idea, but because the image we are loading is remote, we don't know the width and height. Other frameworks like Gatsby Image handle this internally, but Next.js puts the onus on the developer to figure out the width and height. So how do we handle this?

Option 1: standardize your image sizes

While this solution won't work for most, one thing you can do is make sure that all your remote images are the same, or a standardized aspect ratio. This will allow you to hardcode a height and width knowing that the aspect ratio will be preserved

Option 2: use the "fill" property

Another solution is to put your image in a div container with relative positioning. This takes advantage of CSS to size and position the image.

<div style="position: relative; width: 500px;  height: 300px;">
  <Image
    src="some-image.png"
    layout="fill" // Will size the image to fill the parent container
    objectFit="contain" // see - https://developer.mozilla.org/en-US/docs/Web/CSS/object-fit
    objectPosition="center" // see - https://developer.mozilla.org/en-US/docs/Web/CSS/object-position
  />
</div>

Option 3: probe your remote images for their size

This is the option I use and involves getStaticProps. Since getStaticProps runs prior to the page render, we can asynchronously fetch the width and height of our images with the probe-image-size NPM package, pass these sizes to our props, then populate them on the image. Every app will be slightly different, but here's the basic strategy.

import probe from "probe-image-size";

function ExampleComponent(props) {
  return (
    <Image
      src={props.img.src}
      width={props.img.width}
      height={props.img.height}
      alt={props.img.alt}
    />
  );
}

export async function getStaticProps() {
  // Probably will come from a route param, etc.
  const imgPath = "/public/some-path.png";

  const img = fs.createReadStream(path.join(process.cwd(), imgPath));

  // Read img dimensions
  const probedImg = await probe(img);

  return {
    props: {
      img: {
        width: probedImg.width,
        height: probedImg.height,
        src: imgPath,
        alt: "some dynamic alt attribute",
      },
    },
  };
}

Although this might seem like a hack, it is actually similar to what Next.js does internally to grab the size of local images. Notice how a width and height prop are NOT required for local images:

import myLocalImage from "../public/my-local-image.png";

function ExampleComponent() {
  return <Image src={myLocalImage} alt="some alt" />;
}

Option 4: use the native img element

If you don't care about optimized images or the above options don't work for your use case, you can always just fall back to the default img element.

Using Next.js Image with next-remote-mdx

Disclaimer: by the time you are reading the next few sentences, this blog may very well be using a different image strategy and I may very well forget to update this post. That said, the content below will still be valid!

This blog is built with MDX, which means that the posts are stored on Github in markdown format. I insert images into my posts like this:

![some alt tag description](https://media.zachgollwitzer.com/relative/path/to/image.png)

All of the images are stored on AWS S3 as the origin and I'm utilizing the Next.js Image component to:

  1. Download the image from the origin (S3)
  2. Optimize the image (sizing, compression, etc.)
  3. Cache and deliver the image on the Edge network (CDN)

At the time of writing, there is no straightforward method for using the Next.js Image component with MDX, so I implemented a solution originally posted on Kyle Pfromer's blog which involves a custom, yet simple rehype plugin.

As some background, to render my posts in MDX, I'm using next-mdx-remote. Here's the snippet I'm using to create the mdx source:

const mdx = await serialize(frontmatter.content, {
  mdxOptions: {
    remarkPlugins: [gfm],
    rehypePlugins: [rehypeHighlight, rehypeSlug, rehypeImageSize],
  },
});

Translated, I'm using gfm (Github flavored markdown) as my markdown syntax parser, rehypeHighlight for code snippets like the one above, rehypeSlug to add ID links to each HTML heading and then finally, my custom rehypeImageSize plugin to attach the width and height properties to each of my remote images.

Here is the rehypeImageSize plugin implementation. It uses the probe-image-size package to reach out to AWS S3, probe the image size, and then attach that size (width, height) to the HTML image node. Note that process.env['NX_MEDIA_ORIGIN'] represents the base URL to AWS S3. This allows me to dynamically resolve my relative paths to their origin. You'll also notice that I'm throwing an error if an image isn't found. You could silently ignore that error, but with this strategy, I'll always know whether my blog is missing images at build-time (which I prefer).

import type { VFile } from "vfile";
import { Processor } from "unified";
import { Node } from "unist";
import { visit } from "unist-util-visit";
import probe, { ProbeResult } from "probe-image-size";

interface ImageNode extends Node {
  type: "element";
  tagName: "img";
  properties: {
    src: string;
    height?: number;
    width?: number;
  };
}

export function rehypeImageSize(this: Processor) {
  function isImageNode(node: Node): node is ImageNode {
    const img = node as ImageNode;
    return (
      img.type === "element" &&
      img.tagName === "img" &&
      img.properties &&
      typeof img.properties.src === "string"
    );
  }

  return async function transformer(tree: Node, file: VFile): Promise<Node> {
    const imageNodes: ImageNode[] = [];

    visit(tree, "element", (node: Node) => {
      if (isImageNode(node)) {
        imageNodes.push(node);
      }
    });

    for (const node of imageNodes) {
      let size: ProbeResult | null = null;

      try {
        size = await probe(
          `${process.env["NX_MEDIA_ORIGIN"]}${node.properties.src}`
        );
      } catch (e) {
        throw new Error(`Invalid image: ${node.properties.src}`);
      }

      if (size) {
        node.properties.height = size.height;
        node.properties.width = size.width;
      }
    }

    return tree;
  };
}

Great, we've got our custom plugin, but how is it used?? If you read the next-remote-mdx documentation, you'll see that you can add custom components to the rendered MDX:

<MDXRemote {...source} components={components} />

These components can even replace native HTML elements. To use the custom plugin, I'm actually swapping out all the native img elements with my custom Image implementation. Note that although I'm using Next.js Image here, you could in theory use whatever you'd like! To make this work, first create a custom image that extends the native img element:

import type { HTMLProps } from "react";
import NextImage from "next/image";

export function Image({
  src: relativePath,
  alt,
  height,
  width,
}: HTMLProps<HTMLImageElement>) {
  return (
    <NextImage
      src={`${relativePath}`}
      alt={alt}
      // These are dynamically provided at build-time by `rehypeImageSize`
      height={height}
      width={width}
    />
  );
}

And finally, you just need to swap out img in your MDX components prop:

const components = {
  img: Image, // Swap out native img component with custom Next.js Image implementation
};

function YourMDXApp() {
  return (
    <MDXProvider components={components}>
      {/** Your HTML content goes here */}
    </MDXProvider>
  );
}

With this implementation, you can get the best of both worlds! You can use regular markdown to link to your images and leverage Vercel's image optimization API (or an external API like Cloudinary if you're not deployed to Vercel) to serve your images over a CDN at an optimized size!