SVG sprite icons in Next.js

September 9, 2023 (1y ago)

Cover Image for SVG sprite icons in Next.js

SVG's are the easiest way to embed icons into any website. And because they are valid HTML, it might seem like a perfectly fine thing to just add all your SVGs into the JSX of your React app. However, SVG's are some of the most expensive things to add to your Javascript bundle.

SVGs are optimised to be embedded into HTML, however, when we add it to the Javascript bundle, it adds unnecessary bloat to our JS and blocks rendering until it is parsed and executed. Not sure you believe me? Check out this output JS bundle that includes SVGs inside JSX.

And no, this is not an issue because of a lack of tree shaking, this is an issue because the SVG is inside React.createElement. We just want to render SVGs, we don't want React to manage this.

The easy solution is to use SVGs as image assets directly, rather than as JS embedded image assets. Basically, leave it in an .svg file, rather than inside of a .jsx file.

Jacob Paris has an amazing article about how this can be solved using a sprite sheet of SVG assets.

99% of his article applies here, so I would recommend you read that first. At the end, he shows how this can be used in Remix.

I want to walk through how I got this same strategy to work on Next.js and some of the different pitfalls I ran into along the way.

TLDR; Just show me the code

https://github.com/Jacob-Roberts/nextjs-image-sprite

Getting Started

The first thing I did was copy from the Kent C. Dodds' Epic Stack repo which has the solution proposed by Jacob Paris implemented in Remix.

It's almost exactly what we need.

First, I created an other folder in my root directory, and copied the sly folder, svg-icons/README.md, and build-icons.ts into it.

NPM Dependencies

To run this script, lets first install all the dependencies:

npm i -D @sly-cli/sly esbuild execa fs-extra @types/fs-extra glob node-html-parser tsx

And note that these are all dev dependencies, they are never shipped to your users.

While we're here, let's also setup the scripts that we will use to build the icons.

Add the following to your scripts

{
"scripts": {
"build:icons": "tsx ./other/build-icons.ts",
"add:icon": "sly add",
"predev": "npm run build:icons --silent",
...
}
}

Output and Icons

The first thing we're going to change is the output folder.

The script we copied has the output folder set to app/components/ui/icons on line 10.

Here is where Next.js differs from Remix. Next.js has a custom Webpack SVG loader that has some differences. More details below, but for now you'll need to modify the script to output the sprite.svg file to the public folder, and the rest can go wherever you want.

I've set it up so I have a publicDir and an outputDir.

const publicDir = path.join(cwd, "public");
const outputDir = path.join(cwd, "src", "icons");
await fsExtra.ensureDir(publicDir);
await fsExtra.ensureDir(outputDir);

Now you'll need to go edit the generateIconFiles function to use these new folders

async function generateIconFiles() {
const spriteFilepath = path.join(publicDir, "sprite.svg");
const typeOutputFilepath = path.join(outputDir, "name.d.ts");
...

// Further down at line 81 at the time of writing
const readmeChanged = await writeIfChanged(
path.join(outputDir, "README.md"),
...
}

Perfect, now you are ready to build the icons!

Let's go ahead and add an icon for you to use

npm run add:icon

Go ahead and choose one, I always need to use the chevron and hamburger icons, so I'll go ahead and download the chevron-down and hamburger-menu icons from radix ui.

Now you're ready to go!

React

To use the SVG in React, let's first copy the Icon component from the Epic Stack:

import type { SVGProps } from "react";

import type { IconName } from "../icons/name";
import { cn } from "../lib/utils.ts";

const href = "/sprite.svg";

export { href };
export type { IconName };

const sizeClassName = {
font: "w-[1em] h-[1em]",
xs: "w-3 h-3",
sm: "w-4 h-4",
md: "w-5 h-5",
lg: "w-6 h-6",
xl: "w-7 h-7",
} as const;

type Size = keyof typeof sizeClassName;

const childrenSizeClassName = {
font: "gap-1.5",
xs: "gap-1.5",
sm: "gap-1.5",
md: "gap-2",
lg: "gap-2",
xl: "gap-3",
} satisfies Record<Size, string>;

/**
* Renders an SVG icon. The icon defaults to the size of the font. To make it
* align vertically with neighboring text, you can pass the text as a child of
* the icon and it will be automatically aligned.
* Alternatively, if you're not ok with the icon being to the left of the text,
* you need to wrap the icon and text in a common parent and set the parent to
* display "flex" (or "inline-flex") with "items-center" and a reasonable gap.
*/
export function Icon({
name,
size = "font",
className,
children,
...props
}: SVGProps<SVGSVGElement> & {
name: IconName;
size?: Size;
}) {
if (children) {
return (
<span
className={`inline-flex items-center ${childrenSizeClassName[size]}`}
>
<Icon name={name} size={size} className={className} {...props} />
{children}
</span>
);
}
return (
<svg
{...props}
className={cn(sizeClassName[size], "inline self-center", className)}
>
<use href={`${href}#${name}`} />
</svg>
);
}

Here is where Next.js differs from Remix.

Because Next.js registers a custom webpack .svg loader, we can't actually import this SVG sprite using webpack. So sadly, we can't use import href from './icons/sprite.svg'.

It's probably possible with a smart webpack config change, but it was impossible with my testing.

So rather than using an auto-hashed URL, we rely on the public base URL. While not ideal, this will work perfectly and you could start using it just like that!

Next.js Pre-load

In order to preload the asset in Next.js, you simply add a <head> tag to your root layout and preload the sprite.

import { href } from "components/Icon";

const RootLayout = () => {
return (
<html>
<head>
<link rel="preload" href={href} as="image" />
</head>
<body>{children}</body>
</html>
);
};

Caching

One of the main reasons we did this was to get performance benefits of only caching & downloading a single file that isn't parsed by the JS engine.

Luckily, we can easily reproduce the webpack caching behavior in Next.js

First, open up the build-icons.ts file and import the crypto module, so we can use the create hash function

import crypto from "node:crypto";

Now, head down to the very bottom of the generateSvgSprite function and replace return writeIfChanged(outputPath, output); with the following

//...

// Defining the algorithm
const algorithm = "sha256";

// Creating the digest in hex encoding
let digest = crypto.createHash(algorithm).update(output).digest("hex");

// shorten digest to 10 characters
digest = digest.slice(0, 10);

return writeIfChanged(`${outputPath}.${digest}.svg`, output);
}

Now if you run npm run build:icons again, you will see a new sprite.{hash}.svg file in your public folder.

The only thing you'll have to keep in sync is the const href="/sprite.{hash}.svg"; URL in your React component. (But I'm sure you can modify the script to do this for you ๐Ÿ˜‰)

Next.js Config

The last thing to do to make caching work is to actually set the proper cache headers on your sprite file.

To do this open your next.config.js file and add the following headers config:

async headers() {
return [
{
source: "/sprite.:hash.svg",
locale: false,
headers: [
{
key: "Cache-Control",
value: "public, immutable, max-age=9999999999, must-revalidate",
},
],
},
];
},

And with that you are all set! You now can get the full benefits of SVG sprites in a Next.js app!

Special thanks to Jacob Paris and Kent C. Dodds for their initial work on this concept.