Extending Next.js' <Image /> component to improve UX

If you have worked on Next.js, it's a good chance that you might have ended up using the Image component.

While the Next's Image component already has a lot of built in features like blurring placeholder images when the images are loading or controlling image quality to improve the UX.

In this article, we explore extending the Next's image component to improve the end user experience alternatively.

Plot

Here we address 2 main states when serving images

1. Loading state

It is a sort of best practice to lazy load below the fold images on fast websites. Lazy loading images contributes to better UX since it helps in reducing the load time among other things, however, to improve it one step further we add something like an intermediate form of display until the image loads. For example, spinner or a skeleton loader

2. Errored state

What happens if the image url is incorrect or if the image service API is down for some reason? It would be ideal to have a fallback image so the end user has a seamless experience and doesn't end up seeing something like this.

When using Next.js' Image component, it is important to wire it up with a fallback image because of domains.

After we've taken care of these 2 states, the solution ends up looking like this:

ImageWithState

Let's dive in and extend the Next.js' component to further support the aforementioned states.

Starting with the main imports

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

Now, we create a React component that simply extends the Next's Image component & also the types

type ImageWithStateProps = ImageProps

function ImageWithState (props: ImageWithStateProps) {
  return <Image {...props} />
}

So far, we've done nothing other than adding a transparent abstraction over the Image component. The component ImageWithState will work same as Next's Image, only that the component name is different.

Let's now introduce the states

function ImageWithState (props: ImageWithStateProps) {
  const [loading, setLoading] = React.useState(true)
  const [onErrorSrc, setOnErrorSrc] = React.useState<string | undefined>(undefined)

  return <Image {...props} />
}

When the component mounts, loading is set to true by default as the image would start loading

The onErrorSrc prop is the source url for the fallback image. The fallback image appears when the Image component throws an error. Let's go ahead and create the function to handle the errored state

function handleOnError (e: React.SyntheticEvent<HTMLImageElement, Event>): void {
    e?.currentTarget?.src !== props.fallback && setOnErrorSrc(props.fallback)
  }

This is triggered by the onError event

return <Image {...props} onError={(e) => handleOnError(e)} />

The handleOnError function is called when the component would error. In that case we change the src prop of the element to the fallback image.

Now, we manage the loading state

return (
    <div style={{ position: "relative" }}>
      {loading === true && (
        <SkeletonLoader
          style={{
            position: "absolute",
            zIndex: props.debug === "true" ? 99 : "auto"
          }}
          height={props.height}
          width={props.width}
        />
      )}
      <Image
        {...props}
        src={onErrorSrc || src}
        onLoadingComplete={() => !props.debug && setLoading(false)}
        onError={(e) => handleOnError(e)}
      />
    </div>
  );

To represent the loading state, I have used SkeletonLoader component. Feel free to use any other loading indicators like spinners or splash based on your use case.

Furthermore, there is a debug prop which can be helpful when developing & styling to check if the loading indicators are styled appropriately.

Mostly images are served from disk cache, in that case it becomes difficult to replicate a "loading" state for an image while developing. In such a situation, enabling the debug prop would provide a much efficient dev workflow than network throttling via the browser's dev tools.

If you haven't noticed yet, we pass the same height and width prop to the skeleton loader. This also further helps in avoiding layout shift as the space will be preserved for the image.

Finally, updating the type

type ImageWithStateProps = ImageProps & {
  fallback: string;
  debug?: string;
};

Usage

Using the wrapper component should be same as using the Next's Image component.

The ImageWithState component added 2 extra props which are for the fallback image in case of an error & a debug prop to help us make sure that the skeleton loader displays appropriately

Feel free to fork or play around this component on CodeSandbox

You can also visit https://f1icr.csb.app/ to check out the working solution

Caveats

Build size: These changes, including adding the svg skeleton loader library which is react-skeleton-loader adds approximately 5kB to your production build. But keep in mind that this component is reusable across your entire app so the build size won't bloat up further

Web vitals: Next's Image component loads the images lazily by default. If you're loading the images above the fold, remember to pass the priority prop so the page doesn't lose out on LCP points

Blur: It's possible to display a blurred image placeholder when the image is being loaded. This is supported by the placeholder property on the Image component.

Unoptimized: You may have seen an unoptimized true prop passed to the Image component. This is because I haven't setup any loader configuration for these images. Make sure to keep your images optimised and pass srcSet, (now device sizes in Next.js) & responsive images in modern image formats!

Conclusion

Presently, Next.js' Image component only supports displaying a blurred placeholder when an image is being loaded, something like what you may have seen on medium.com.

As one solution doesn't fit all use cases, this write-up represents an alternate way to wire up the Image component while still keeping the end user experience in mind.

16