ImageEngine custom loaders for Next.js
Tags: imageengine, nextjs, vercel, loader
Published: 2021-06-29 16:35:23


This article will guide you through writing custom Next.js loaders, specifically for `ImageEngine`’s distribution, but is applicable to any other situation where you need to customise the loaders. We’ll be building a grid layout image portfolio with the option to view the images in a full window lightbox. You can check the [app we’ll be building here](https://ie-custom-loader.vercel.app) and the [github repo](https://github.com/mnussbaumer/ie-nextjs-loader).

### Summary:
- Intro
- Creating a `Next.js` App
- Defining our Layout and Content
- Setting up `Custom Loaders` for use by our `Next.js Image` components
- Deploying on vercel.com
- Create a distribution with `ImageEngine` and set our vercel environment to use it
- Conclusion


###Intro

[ImageEngine](https://imageengine.io) has custom image components for [React](https://www.npmjs.com/package/@imageengine/react) and [Vue](https://www.npmjs.com/package/@imageengine/vue), and in another post we went through [how to use ImageEngine's custom image component](https://dev.to/mnussbaumer/imageengine-with-nextjs-1dh7) in a `Next.js`.

`Next.js` `<Image/>` component is a pretty neat tool that is available in any `Next.js` project you might start. It allows one to set up a system of breakpoints, sizes and settings to apply to our image assets. It takes care of preventing [Cumulative Layout Shift](https://web.dev/cls/) on page rendering when provided with the correct size information and optimize our assets according to buckets of resolution/viewport/image sizes. 

It’s especially useful if you’re deploying on [Vercel](https://vercel.com) as it will be automatically enabled and performant. It works by generating optimized assets in terms of dimensions, and then caching those resources for subsequent requests on their edge servers. This for many cases will be enough and offers a much better experience than unoptimized assets. 

On the other hand, if you’re not running on `Vercel`’s hosting, the performance of initial optimization is not as fast and can take a bit of time. If you’re not doing `SSR` and instead are deploying a static website build, as of now, you won’t be able to use their `Image` component at all.

It also works better when the assets you’re serving all fall into horizontal/square ratios, as it uses `width` to determine the optimized images. So if you have a reasonable mix of vertical and horizontally oriented images then it will be much harder to serve the minimum needed sizes for those vertical images. 

If you’re serving image content in very high quantities, even when using `Vercel.com` then you might be interested in something that allows you to optimize your images up to the single pixel count, doesn’t rely on your server performance nor caching solutions, and offers very fast CDN distributions to deliver the best possible loading and viewing experience to your customers and users.

In this post we’ll review how to setup such a project by designing two different components with two different loaders, one for square thumbnails and one for a full window lightbox. In the process we’ll see how to write a custom `Image` loader for `Next.js` and some tricks for layout responsiveness.


###Creating a `Next.js` App

To follow through you'll need to have nodejs and npm installed. The versions used for this tutorial are npm 7.15.1 and node v16.3.0. We’ll also install the [vercel CLI](https://vercel.com/cli).

On the folder where you'll be creating your project run:

`npx create-next-app`

Now let's install the [ImageEngine Package](https://www.npmjs.com/package/@imageengine/react):

`npm i @imageengine/react`

We should be able to start our app by doing

`npm run dev`

And visiting 

`http://localhost:3000`

This should show us the default page. Let's remove the default templating `index.js` page has and replace it with:


```js
import Head from "next/head";

export default function Home() {
  return (

      <div className="main-container">
        <Head>
            <title>ImageEngine Optimized Lightbox</title>
            <meta name="description" content="Next.js custom image loaders leveraging ImageEngine CDN for highly optimized images" />
            <link rel="icon" href="/favicon.ico" />
         </Head>
      </div>
  );
};
```

And change `/pages/_app.js` to the following

```js
import Head from "next/head";
import "../styles/globals.css";

function IECustomLoader({ Component, pageProps }) {

    return (
	    <>
	      <Head>
		<link rel="icon" type="image/png" href="/favicon.png"/>
		<meta name="viewport" content="initial-scale=1.0" />
		<meta name="description" content="Learn how to use ImageEngine with Nextjs custom loaders to serve highly optimised image assets from your CDN to your users." />
		<meta property="og:title" content="ImageEngine with NextJS" />
		<meta property="og:description" content="Learn how to use ImageEngine with Nextjs custom loaders to serve highly optimised image assets from your CDN to your users." />
	      </Head>
	      <Component {...pageProps} />
	    </>
    );
};

export default IECustomLoader;
```

Create a `next.config.js` on the root of our project and inside it:

```js
module.exports = {
    env: {
	DISTRIBUTION: process.env.DISTRIBUTION
    },
    images: {
	deviceSizes: [1920, 1500, 1000, 500, 300],
	imageSizes: []
    }
};
```

Let's remove the things we don't need, delete `pages/api` folder, `styles/Home.module.css` and `public/vercel.svg`::

`rm -rf pages/api`
`rm styles/Home.module.css`
`rm public/vercel.svg`

Create a folder at the root level named `components`, and inside the `public` folder create a `ie-loader-images` folder.

We should now have the following structure:

```
...
pages /
  _app.js
  index.js

styles /
  global.css

components / 

public /
  favicon.ico
  ie-loader-images
```

You’ll also need to download the files in [public/ie-loader-images](githublink) and place them  in `/public/ie-loader-images/`.

For this web page we’ll need 2 components, a `thumbnail` component and a `lightbox` component. Both components will receive a `src` and `alt` props for the image, an `onClick` function and the sizes of the `viewport`. The `lightbox` component will additionally receive two functions for moving to the next and previous image.

The CSS is important but to save space it won’t be written here, you can copy it over from [globals.css](https://github.com/mnussbaumer/ie-nextjs-loader/blob/main/styles/globals.css).

As a side note, `Next.js` `Image` component fixes the size of the image to prevent `Cumulative Layout Shift`, but since we establish fixed size containers in CSS, that would have been addressed as well. The other interesting bit there is the small CSS trickery to get the `hover` effect to display the labels without JS. The remaining is basic styling.

###Thumbnail Component

Create a file `components/thumbnail.js` with:

```js
import Image from "next/image";
import { constructUrl } from "@imageengine/react/build/utils/index.js";

import { useState, useEffect, createRef } from "react";

function thumbnail_loader({ src, quality, distribution, width }) {
    let url = distribution + src,
	directives = {
	    width: width,
	    height: width,
	    fitMethod: "cropbox",
	    compression: 100 - quality,
	    sharpness: 10
	};
    
    return constructUrl(url, directives);
};

export default function Thumbnail({ onClick, src, alt, window_sizes }) {

    let thumbnail_ref = createRef(),
	[width, set_width] = useState(null),
	[initial_size, set_initial_size] = useState(null);

    useEffect(() => {
	if (window_sizes) {
	    
	    let dimensions = thumbnail_ref.current.getBoundingClientRect(),
		n_width = Math.ceil(dimensions.width);

	    
	    if (!initial_size) { set_initial_size(n_width); }

	    if (initial_size >= n_width) { set_width(initial_size); }
	    else {
		
		set_initial_size(n_width);
		set_width(n_width);
		
	    }
	}
    }, [window_sizes]);
    
    return (
	<div className="image-thumbnail" onClick={onClick} ref={thumbnail_ref}>
	  {width ?
	      <Image
		    src={src}
		    alt={alt}
		    sizes={`(max-width: 320px) 300px, (max-width: 500px) 500px, (max-width: 1000) 1000px, (max-width: 1500) 1500px, ${width}px`}
		    layout="responsive"
		    objectFit="cover"
		    objectPosition="center"
		    width={width}
		    height={width}
		    loader={process.env.DISTRIBUTION ? (args) => thumbnail_loader({...args, distribution: process.env.DISTRIBUTION}) : undefined}
		    quality={80}/> : null
		}
		<div className="image-details">
		  <p>{alt}</p>
		</div>
	</div>
    );
};
```

Let’s go through the component part first. We receive 4 props `onClick, src, alt, window_sizes`. We create a `DOM ref` that we can use to get the actual HTML element of our wrapper, and we set two `useState`’s, one to store the current width, and another the store the initial width. Most times these will be the same. 

Then we use `useEffect` with a dependency on the value of `window_sizes`. Meaning this will run on mount once and then any time the prop `window_sizes` changes. Inside this hook if the `window_sizes` has a value, we use the `ref` for getting the wrapper component dimensions, we round the value, set the `initial_size` if it’s not set yet, and, then, if the new width is bigger than the the `initial_size` we set both `initial` and `width` to this new size, and if not we keep the `initial_size` and set it as our `width` value. 

The reason we do this is because if we have already fetched an image with bigger dimensions than the new size there’s no point in fetching the smaller one, since the bigger one can be displayed just as correctly and has more quality. On the other hand, if the window resize resulted in a thumbnail that is bigger than initially, we want to fetch a bigger image to keep the quality at a higher size. We’ll also replace the initial whenever that’s the case, so that resizing smaller and back into bigger won’t retrigger a new image.

Then our component itself is a wrapper with a property of `onClick` set to the handler passed down as a prop (that will open the `lightbox`) and the `ref` we created, so that we can use the `ref` to get the wrapper width.

Inside the wrapper we have the `Next.js` `Image` component, and we set `src`, `alt`, `sizes` (for the breakpoints), `layout` as responsive (so that it fetches new images if the width and sizes change), `objectFit` and `objectPosition` so that it forces the image to be displayed covering the full “square” and centered, `quality`, and in case there’s an environment variable `DISTRIBUTION` set, we use the specific loader, an anonymous function that calls the `thumbnails_loader` we defined at the top of the file with an additional argument, the`distribution` url, otherwise we set it as null so the default one will be used.

When `DISTRIBUTION` is set that loader will be used to provide the actual `url` the `Image` component will use. By default the loader has access to 3 params provided by `Next.js`, the original `src`, `quality` value and `width`. In this case since it’s a square image, the `width` is enough, but we still need the `distribution` url, so that we can generate the right source for our image.

By using the `constructUrl` provided by [@imageengine/react](https://www.npmjs.com/package/@imageengine/react) we can pass an object with properties that dictate how the CDN will provide the images. Here we pass `width`, `height`, `fitMethod`, `compression` (it’s the inverse scale from the quality) and `sharpness` to apply a small amount of sharpness to the final image.

**note** as of now, `constructUrl` isn’t directly exported by the package - which means that although probably unlikely, it’s place in the lib might change - nonetheless it will probably be made public in a future release, but otherwise, you can always either copy the specific logic for it, or use [directives](https://support.imageengine.io/hc/en-us/articles/360058880672-directives) directly and build your own url.

Notice that this will generate the same url for all `srcset sizes` entries, and that is fine since the url codifies the needed settings itself, but when using `ImageEngine` we don’t really need to rely on `srcset` since we can generate exactly the sizes we want on-the-fly.

###Lightbox Component

Now let's move to the `lightbox`component, create the file `components/lightbox.js`:


```js
import Image from "next/image";
import { constructUrl } from "@imageengine/react/build/utils/index.js";

function lightbox_loader({ src, quality, distribution, w, h }) {
    let url = distribution + src,
	directives = {
	    width: w,
	    height: h,
	    fitMethod: "box",
	    compression: 100 - quality
	};
    
    return constructUrl(url, directives);
};

export default function Lighbox({ onClick, src, alt, window_sizes, previous, next }) {

    return (
	<div className="lightbox" onClick={onClick}>
	  <button className="previous" onClick={previous}/>
	  <div className="image-lightbox">
	    {window_sizes ? <Image
				  src={src}
				  alt={alt}
				  sizes={`(max-width: 320px) 300px, (max-width: 500px) 500px, (max-width: 1000) 1000px, (max-width: 1500) 1500px, ${window_sizes.w}px`}
				  objectFit="contain"
				  objectPosition="center"
				  width={window_sizes.w}
				  height={window_sizes.h}
				  loader={process.env.DISTRIBUTION ? (args) => lightbox_loader({...args, ...window_sizes, distribution: process.env.DISTRIBUTION}) : undefined}
		  quality={90}
	      />  : null}
	  </div>
	  <button className="next" onClick={next}/>
	</div>
    );
};
```

You’ll notice that this is very similar to the thumbnail  component, but that we use instead the `w` and `h` values directly from the `window_sizes` prop, and we use a different `objectFit` and `quality`.

The reason we pass `width` and `height` to our loader is that since we want the image to fit the viewport, with both those values and the `box` fit, `ImageEngine` will be able to generate perfectly sized images for them, especially when they’re vertical.

In the intro I mentioned that cases where the image orientation is not horizontal, that `ImageEngine` can provide better optimisations and the reason is related to how the `Next.js` `Image` component works. 

When we set `deviceSizes: [1920, 1500, 1000, 500, 300]` on our `next.config.js` what we’re saying is, we’ll have 5 buckets of width sizes we want to allow. By using the `sizes` property on the `Image` component we can make it so that given a certain `width` the browser will try to match it with the smallest possible `srcset` that covers that `width`. For horizontal or square images this works fine, as there’s a direct match between the max width and the bucket. But when the image is vertical, this no longer matches neatly. So if you open the lightbox with a vertical image and the viewport size is `1800px` wide, but only `750px` high, the image that will be retrieved will be the one matching the `1920` entry. This is actually a bigger image since it’s vertical, it’s width to match `1920` will make it quite bigger than it needs to be. 

When using `ImageEngine`, in the same situation, with a [fitMethod of box](https://support.imageengine.io/hc/en-us/articles/360058880672-Directives), `ImageEngine`’s engine will actually be smart enough to see that the image needs to be `750px` high at most, and will resize it by that axis. So the result might be a `400px X 750px` image, instead of `1920px X 3000px` for instance. 

If we had exact information for both width and height of the actual images, we could try to work around that limitation of the `Next.js` optimizer, by doing ourselves the calculations of what width would correspond to that maximum height - but since this isn’t normally available we usually can’t. Plus, that would need to change whenever we had changes on layout, styling, or on the source images.

With `ImageEngine` we don’t need to worry about that, since it’s always going to be the perfect fit for the dimensions we give it. Plus, in the case of the thumbnails, if we changed the size of the wrappers in CSS, it would still work automatically, whereas with the default `Image` component we might need to change our `deviceSizes` or do small adjustments. We could even make the `thumbnail` element be completely future proof if instead of providing only the `width` (as it was a square) we also provided the `height` of the wrapper, so that even if we changed the styling to a different ratio it would still work correctly. Since it’s a demo, we’re using only one side but there’s no reason to not use both as we already have the DOM element from which we can extract the height as well.

The last part is to define our main `index.js` file.

###index.js entry point

Let’s replace the contents of `pages/index.js`:

```js
import Head from "next/head";

import Thumbnail from "../components/thumbnail.js";
import Lightbox from "../components/lightbox.js";

import { useEffect, useState } from "react";

const IMAGES = [
    ["/ie-loader-images/h-lightbox-1.jpeg", "Harvested field with hay bales - Alentejo, Portugal © Micael Nussbaumer"],
    ["/ie-loader-images/h-lightbox-2.jpeg", "Family cycling and skating in abandoned Tempelhof Airport lane - Berlin, Germany © Micael Nussbaumer"],
    ["/ie-loader-images/h-lightbox-8.jpeg", "Group of hindu and muslim kids posing for a photo - New Delhi, India © Micael Nussbaumer"],
    ["/ie-loader-images/v-lightbox-4.jpeg", "Buddhist Monk Portrait with a statue of the buddhist mythological Seven Headed Naga  serving as background - Siem Reap, Cambodia © Micael Nussbaumer"],
    ["/ie-loader-images/h-lightbox-5.jpeg", "Traditional Nepalase Hindu Temple in one of the many lively city squares of Kathmandu, Nepal © Micael Nussbaumer"],
    ["/ie-loader-images/v-lightbox-6.jpeg", "Woman in traditional Nepalese clothing sitting in a valley in Pokhara, Nepal © Micael Nussbaumer"],
    ["/ie-loader-images/h-lightbox-7.jpeg", "Geometric pattern on a ceiling inside the Red Fort - New Delhi, India © Micael Nussbaumer"],
    ["/ie-loader-images/h-lightbox-3.jpeg", "Kids silhuetes in the sea near-shore close to sunset - Phu Quoc Island, Vietnam © Micael Nussbaumer"],
    ["/ie-loader-images/h-lightbox-9.jpeg", "Portrait of four man sitting during a pause in their badminton game - Hanoi, Vietnam © Micael Nussbaumer"]
];


export default function Home() {

    let [opened, set_opened] = useState(false);
    let [window_sizes, set_window_sizes] = useState(null);
    let resize_timer;

    const get_window_sizes = () => {
	let doc = document.documentElement;
	return {w: doc.clientWidth, h: doc.clientHeight};
    };

    const previous = (evt) => {
	evt.stopPropagation();
	if (opened > 0) { set_opened(opened - 1); }
	else { set_opened(IMAGES.length - 1); }
    };

    const next = (evt) => {
	evt.stopPropagation();
	if (opened < (IMAGES.length - 1)) { set_opened(opened + 1); }
	else { set_opened(0); }
    };

    const set_timing = () => {
	if (resize_timer) { clearTimeout(resize_timer); }
	resize_timer = setTimeout(
	    () =>  {
set_window_sizes(get_window_sizes());
resize_timer = null;
    }, 2000
	);
    };

    useEffect(() => {
	window.addEventListener("resize", set_timing);
	set_window_sizes(get_window_sizes());

	return () => window.removeEventListener("resize", set_timing);
    }, []);
    
    return (
	<div className={`main-container ${opened || opened === 0 ? "no-overflow" : ""}`} >
	  <Head>
            <title>ImageEngine Optimized Lightbox</title>
            <meta name="description" content="Next.js custom image loaders leveraging ImageEngine CDN for highly optimized images" />
            <link rel="icon" href="/favicon.ico" />
	  </Head>
	  <main>
	    {IMAGES.map(([path, alt], index) => {
		return <Thumbnail key={`image-thumb-${index}`} src={path} alt={alt} onClick={(_evt) => set_opened(index)} window_sizes={window_sizes}/> ;
		  })
	      }
	  </main>
	  {opened || opened === 0 ? <Lightbox src={IMAGES[opened][0]} alt={IMAGES[opened][1]} onClick={(_evt) => set_opened(null)} window_sizes={window_sizes} previous={previous} next={next}/> : null}
	    <footer>
	      <a href="https://imageengine.io" target="_blank">ImageEngine</a>
	      <a href="https://micaelnussbaumer.com" target="_blank">© Micael Nussbaumer 2021</a>
	      
	      <a href="https://nextjs.org" target="_blank">Next.js</a>
	      <a href="https://vercel.com" target="_blank">Vercel</a>
	    </footer>
	</div>
    );
};
```

In our `index.js` file we define an `IMAGES` array, that contains the images we’ll show. The contents are mostly straightforward. We use `useState` to set both the window size and if the lightbox is open. We define functions for getting the window size, for moving forward and backwards across the images, and to control any possible resizing of the viewport.

On first mount, we `useEffect` to set a listener on the `window resize` event and we set the original window sizes. Although we only have one page in our `Next.js` app, we also set a cleanup function on the `useEffect` by returning an anonymous function to remove the `eventListener` we added previously. This is just good form, because if you’re using React as a SPA with multiple pages and you don’t clean-up your useEffects, you might end with memory-leaks or multiple listeners being triggered (and most likely throwing exceptions since what they’ll be referring to won’t be around anymore).

Regarding the `resize` event we use a `setTimeout` and a control variable `resize_timer`. This is so that we don’t trigger multiple `window_sizes` changes - as that would trickle down to our components and trigger multiple fetches for different image sources as the dimensions changed, for instance if resizing the window on a desktop manually. At the same time, listening to resize events and updating the `window_sizes` takes care of refetching the correct size in case a user is seeing the website on a mobile and changes the display from vertical to horizontal for instance. 

The `JSX` contents are pretty regular, we map our `IMAGES` array into `Thumbnail`’s components passing `src`, `alt`, `window_sizes` and a `onClick` handle.

Then when clicking one of the thumbnails, the `opened` state is updated to the index of that image in our array and we use that to both display our `Lightbox` component and to read the correct data for our image. On the `Lightbox` we pass the same props plus the `previous` and `next` functions.
Lastly we have a few `footer` items.
And that’s it.

###Setting up ImageEngine

With this we can already host our page in `vercel`, by using their CLI from our project’s root folder executing:

`vercel` 

And setting up the project settings, followed by:

`vercel --prod`

...to deploy. 

You’ll now be able to see the website online. Take note of your production server url.
Once that is done it’s time to setup the `ImageEngine` distribution.

You can follow this video on how to easily signup for a trial

{% vimeo 552953993 %}
[imageengine.io](https://imageengine.io)

After following those steps, take note of the delivery address, because we'll use it when building our project. We can do it through the `Vercel` dashboard for our project, or through the `CLI` when deploying:

`vercel --build-env DISTRIBUTION=”https://our_ie_address.imgeng.in” --prod`

With that, our custom loaders will be enabled, and if we visit our website and inspect the HTML source we should see that it’s now using our `ImageEngine` distribution for the images!

###Conclusion

While `Next.js`’s `Image` component along with `vercel`’s automated infrastructure is really a great improvement over using non-optimized assets and will work well for many types of websites, there’s a few cases where **ImageEngine is a better option overall**. 

Those include websites with very high traffic and image content. The more targeted, and almost pixel precise, sizes it allows one to deliver, can lower significantly the sizes of images being transferred. Websites with mixed orientation content in any significant quantity also benefit widely from it. 

Another situation is, if you’re using `Next.js` but not deploying on `vercel` then you’ll probably want to use it too - the `Image` component works the best possible in `vercel` as its infrastructure is prepared to handle it specifically (they’re the authors of `Next.js`) but if you’re deploying on your own server then it’s a different matter and having it stored, prepared and cached over a very fast external CDN will help you achieve the best performance for your website. 

Lastly, as we saw, by taking some measures when writing the logic for the loader, we can make it so that our components are automatically able to handle changes in layout, styling or structure without any tweaking or re-doing of the app settings or components logic while keeping the exact needed sizes for any dimensions we decide to use. This is important as it removes one friction point when contemplating a re-styling of the whole or parts of the app.