DEV Community

loading...

ImageEngine custom loaders for Next.js

Micael Nussbaumer
Elixir, JS, Vue, Nuxt, React, Postgresql 6++years
・14 min read

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 and the github repo.

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 has custom image components for React and Vue, and in another post we went through how to use ImageEngine's custom image component 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 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 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.

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

npx create-next-app

Now let's install the ImageEngine Package:

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:

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

And change /pages/_app.js to the following

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;
Enter fullscreen mode Exit fullscreen mode

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

module.exports = {
    env: {
    DISTRIBUTION: process.env.DISTRIBUTION
    },
    images: {
    deviceSizes: [1920, 1500, 1000, 500, 300],
    imageSizes: []
    }
};
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

You’ll also need to download the files in public/ie-loader-images 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.

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:

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>
    );
};
Enter fullscreen mode Exit fullscreen mode

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, thedistribution 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 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 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 lightboxcomponent, create the file components/lightbox.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>
    );
};
Enter fullscreen mode Exit fullscreen mode

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, 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:

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>
    );
};
Enter fullscreen mode Exit fullscreen mode

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


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.

Discussion (0)