⚔️ Code Conqueror

📸 Image optimization with Next.js

Mar 14, 2020

Having large un-optimized images will slow down your site performance dramatically. There's a long list of optimizations that can be made to rendering images, and some useful libraries for handling this in a Next.js application.

Image optimization in Next.js can be dramatically simplified with the use of some very nice open source plugins. With the new static site generation abilities of Next, this is more important than ever, as the framework will likely see more use in image heavy content pages.

Hosting / loading images

The first thing we'll need to get out of the way is the question of where to put images. We'll imagine two common scenarios:

  1. You have an image, not hosted elsewhere, that you want to have in your next.js application and have Next.js deal with everything
  2. You have an image that is hosted on another server, but want to display an optimized version in your Next.js application (eg. using an unsplash image). Optimize the image at build time similar to Gatsby.

Hosting a local image with Next.js

The most straightforward way to host an image in Next.js is just to put it in a directory at the root of the project named

/public/my-image.png
and then point image tags to
<img src="/my-image.png" />
this will host the image at the route
/my-image.png
and is done in a way similar to how express static works for hosting static files on a Node.js server.

This is the most basic way to host an image in your Next.js project — it will work okay, but especially for large images, it can cause issues with page load times both because of the amount of data you are sending in the initial request and because the image format is not as compression optimized as it could be.

Optimizing images in Next.js

This though, can be improved upon a lot. To get started we'll install a Next.js plugin called

next-optimized-images
. This is the core module, and needs several other small modules to be installed along side it in order to optimize specific kinds of images. Since in our case we have a PNG image we'll add:

  1. webp-loader
    which can be used to convert PNG and JPEG images into WebP images (a new format from Google which has much better compression compared to PNG).
  2. One of
    lquip-loader
    or
    image-trace-loader
    . lquip shows a blurred image of the main colors first while the image loads (like Google Images). Trace shows an SVG outline of the image before loading the image (like Kent's blog).

You'll also need to add the plugin to your

next.config.js
like:

// next.config.js
const withOptimizedImages = require("next-optimized-images");
const path = require("path");
module.exports = withOptimizedImages({
webpack(config) {
config.resolve.alias.images = path.join(__dirname, "images");
return config;
},
});

Because we are going to use

require()
to load the images (and thus they go through the webpack plugin) we don't need to store the images in the
/public
folder anymore since they will get moved to
/static/images
during the build step anyways. For our example we'll store the
camera.png
image in
images/camera.png
. We also add a webpack alias so that we don't have to deal with relative paths when requireing the images in our component. We can define a new
Image
React component which we will always use for rendering images on our site:

type ImageProps = {
path: string,
};
const Image: React.FunctionComponent<ImageProps> = ({ path }) => {
return (
<div className="image-container">
<img src={require(`images/${path}?trace`).trace} />
<img src={require(`images/${path}?webp`)} />
<style jsx>{`
.image-container: {
position: relative:
}
img {
position: absolute;
top: 0;
left: 0;
}
`}</style>
</div>
);
};

Let's walk through our component (written in Typescript). We pass the

path
to the PNG inside of the images directory. Within the component we use
require
to load the images. Because of the plugin we installed webpack will understand that what we want to do is:

  1. server redner the svg image trace
  2. lazy load a WebP transformed version of our PNG on the client side

we also add some css using Next.js built-in styled-jsx library that just overlays the image tags on top of each other so that when the WebP image is ready it looks like it replaces the SVG. You can feel free to use raw css files or whicever css-in-js library you prefer.

You can see in the network tab that the WebP image is lazy loaded and the image name is changed to include a content-hash so that it can be cached by a browser or CDN and then invalidated if

camera.png
changes but we kept the name of the file the same.

Customizing the trace images

There are several customization options for the trace images like color, background-color, options for how grainy the svg should be etc. You can configure those within

next.config.js
directly under the
imageTrace
key like:

// next.config.js
const withOptimizedImages = require("next-optimized-images");
const path = require("path");
module.exports = withOptimizedImages({
/* config for next-optimized-images */
imageTrace: {
color: "#fff",
},
webpack(config) {
config.resolve.alias.images = path.join(__dirname, "images");
return config;
},
});

There are many more optimization options documented on the plugin's github.

Using blurred images instead of traces

An alternative to the trace images is to load a low-quality image and apply a css blur filter first and replace it with the higher resolution one. To do this you'll need to install the

lqip-loader
.

Then you can load the image in a similar way as the trace

const Image: React.FunctionComponent<ImageProps> = ({ src }) => {
return (
<div className="image-container">
<img className="blur-image" src={require(`images/${src}?lqip`)} />
<img src={require(`images/${src}?webp`)} />
<style jsx>{`
.image-container: {
position: relative:
}
.blur-image img {
blur(25px);
width: 300px;
height: 200px;
}
img {
position: absolute;
width: 300px;
height: 200px;
top: 0;
left: 0;
}
`}</style>
</div>
);
};

The lqip placeholder is small and therefore inlined as a base64 image, and as such can be delievered in the initial payload while the high res image lazy loads.

Loading images from another resource (eg. S3)

One common thing you might want to do instead of hosting the images statically in your project yourself is to fetch the image from a third-party resource like s3, unsplash etc. The best way to do this is via a proxy endpoint.

You can create an api route like:

// pages/api/image.ts
import fetch from "isomorphic-unfetch";
import { parse } from "url";
import { NextApiRequest, NextApiResponse } from "next";
module.exports = async (req: NextApiRequest, res: NextApiResponse) => {
const {
query: { url },
} = parse(req.url || "", true);
const r = await fetch(
// we get images from notion, but you could get them from AWS etc.
`https://www.notion.so/image/${encodeURIComponent(url)}`,
{
headers: {
"content-type": "image/png",
// maybe an auth header
},
}
);
res.setHeader("content-type", r.headers.get("content-type"));
res.setHeader("cache-control", "s-maxage=1, stale-while-revalidate");
r.body.pipe(res);
};

Essentially all we do here is proxy through to a request of the data on the remote server, possibly passing credentials and setting a caching policy. This prevents secrets from beeing leaked in the javascript bundle if you were to make the request on the client side.

This approach generally works well if the resource you are requesting the image from is highly available and possibly behind a CDN. However, it does not have the same nice optimizations of our local file loading since webpack knows nothing about these images and cannot optimize them. Eg to load these images we just load in an image tag like:

<img src={`/api/image?url=${src}`} />;

Loading images from anoter resource at build time

The way that images are loaded on this site is a hybrid approach. We fetch the images from Notion (AWS) or Unsplash etc and store them in temporary files. Then we do all of our image processing at build time using node utilities to store webp files and generate inline SVG for the traces. We then store the webp files in the

public/
directory (so that we can lazy load our own webp versions) and pass the inline svg through static props. It looks something like:

import React from "react";
import fetch from "isomorphic-unfetch";
import { NextPage } from "next";
import processImage from "lib/image/processImage";
const ImageOptimizationPage: NextPage<{
image: { webp: string, svg: string },
}> = ({ image }) => {
return (
<div className="image-container">
<img src={image.svg} />
<img src={`/images/${image.webp}.webp`} />
<style jsx>{`
.image-container: {
position: relative:
}
img {
position: absolute;
top: 0;
left: 0;
}
`}</style>
</div>
);
};
export const getStaticProps = async ({}): Promise<{
props: { [key: string]: any },
revalidate?: number | boolean,
}> => {
// fetch an image from unsplash
const resp = await fetch(
`https://images.unsplash.com/photo-1558981852-426c6c22a060?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=1351&q=80`
);
const slug = "bike";
const { svg, webp } = await processImage(slug, resp);
return {
props: {
image: {
svg,
webp,
},
},
};
};
export default ImageOptimizationPage;

Some magic is going on in the

processImage
method where we basically take a slug that we want to name the file and the response from fetching it and get back our webp path in
public/images
and the inline svg. This avoids the problem where you cannot use webpack to require files that don't exist prior to
getStaticProps
running unless they are in
public/
eg. we cannot get webpack to generate the trace for us because of this and must do it ourselves.

We'll work on publising the magic

processImage
into a package, but it essentially just mashes the code from image-trace-loader and a simple converstion to webp using sharp. We've published the example code.