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 ourNext.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>
);
};
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;
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: []
}
};
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 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>
);
};
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 lightbox
component, 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>
);
};
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>
);
};
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
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.
Top comments (0)