Using SVG Sprites with NextJS 13
Tags: nextjs, icons, react, svg
Published: 2023-10-16 16:54:25

---
title: Using SVG Sprites with NextJS 13
published: true
date: 2023-10-15 00:00:00 UTC
tags: nextjs,icons,React,SVG
cover_image: https://dev-to-uploads.s3.amazonaws.com/uploads/articles/lku8wkg6stbu8z2nbllh.png
canonical_url: https://www.jamesshopland.com/blog/nextjs-svg-sprites
---

Most of the popular icon libraries for React load the icons in an inefficient way, directly to JSX. There is a great twitter thread about the performance of this [here](https://twitter.com/_developit/status/1382838799420514317), as well as a [great article](https://benadam.me/thoughts/react-svg-sprites/) explaining the different methods and their trade-offs.

Today I'm going to show you how we can utilize svg sprite sheets in NextJs.

https://www.youtube.com/watch?v=_Xrz7FeOtC0

**Credit**<br/>
The code I will be using is slightly modified from Kent C. Dodds Epic Stack [implementation](https://github.com/epicweb-dev/epic-stack/blob/main/docs/decisions/020-icons.md). His is for Remix.js. I have modified a few parts to make it work on NextJs.

{% youtube _Xrz7FeOtC0 %}

## Getting Started


#### 1. Create a folder to hold our icons

In the root level of your NextJs project, create a folder called `/other/svg`.

This is where we store our SVGs files. If you are using different icon libraries, its also good to create subfolders for those, such as `/other/svg/lucide`.

Now to add a icon, you can just copy its svg into a `.svg` file, but I'm going to show you a great tool to automate this for the popular React icon libraries.

[Sly-cli](https://sly-cli.fly.dev/) is a tool that adds code from dependencies, but not the dependencies themselves. This will let us get the icon libraries svg file without all the React layers.

For example, to add the avatar and bell icon for radix icons, we would do this:
```shell
npx @sly-cli/sly add @radix-ui/icons camera card-stack --yes --directory ./other/svg/radix
```

You can also use:
```shell
npx @sly-cli/sly add
```
To use interactive mode to set this up easily from the terminal, as well as see the icons you can use listed, to easily select the ones you want. I will show you my `sly.json` later for a good starter configuration


#### 2. Compiling the icons into a sprite

An SVG sprite utilizes the `<symbol>` for each of our icons, to become an element in one SVG. To generate this, we are going to utilise a script. You will have to run this each time you add a icon. If you used sly, you can set this up to run automatically after you add them. You can see this in my example sly.json I will leave in this blog.

First we need to install some dependencies:
```shell
npm install fs-extra glob node-html-parser @types/fs-extra --save-dev
```
Then we need the script. For NextJs, you want to create a file at the root level called `build-icons.mts` *(Note: the .mts file extension is needed for the default NextJs app, unless you are running your app in module mode)*

[Link](https://github.com/jolbol1/nextjs-svg-sprites/blob/main/build-icons.mts) to script on GitHub, or copy and paste below

```ts
import * as path from "node:path";
import fsExtra from "fs-extra";
import { glob } from "glob";
import { parse } from "node-html-parser";

const cwd = process.cwd();
const inputDir = path.join(cwd, "other", "svg");
const inputDirRelative = path.relative(cwd, inputDir);
const typeDir = path.join(cwd, "types");
const outputDir = path.join(cwd, "public", "icons");
await fsExtra.ensureDir(outputDir);
await fsExtra.ensureDir(typeDir);

const files = glob
  .sync("**/*.svg", {
    cwd: inputDir,
  })
  .sort((a, b) => a.localeCompare(b));

const shouldVerboseLog = process.argv.includes("--log=verbose");
const logVerbose = shouldVerboseLog ? console.log : () => {};

if (files.length === 0) {
  console.log(`No SVG files found in ${inputDirRelative}`);
} else {
  await generateIconFiles();
}

async function generateIconFiles() {
  const spriteFilepath = path.join(outputDir, "sprite.svg");
  const typeOutputFilepath = path.join(typeDir, "name.d.ts");
  const currentSprite = await fsExtra
    .readFile(spriteFilepath, "utf8")
    .catch(() => "");
  const currentTypes = await fsExtra
    .readFile(typeOutputFilepath, "utf8")
    .catch(() => "");

  const iconNames = files.map((file) => iconName(file));

  const spriteUpToDate = iconNames.every((name) =>
    currentSprite.includes(`id=${name}`)
  );
  const typesUpToDate = iconNames.every((name) =>
    currentTypes.includes(`"${name}"`)
  );

  if (spriteUpToDate && typesUpToDate) {
    logVerbose(`Icons are up to date`);
    return;
  }

  logVerbose(`Generating sprite for ${inputDirRelative}`);

  const spriteChanged = await generateSvgSprite({
    files,
    inputDir,
    outputPath: spriteFilepath,
  });

  for (const file of files) {
    logVerbose("✅", file);
  }
  logVerbose(`Saved to ${path.relative(cwd, spriteFilepath)}`);

  const stringifiedIconNames = iconNames.map((name) => JSON.stringify(name));

  const typeOutputContent = `// This file is generated by npm run build:icons

export type IconName =
\t| ${stringifiedIconNames.join("\n\t| ")};
`;
  const typesChanged = await writeIfChanged(
    typeOutputFilepath,
    typeOutputContent
  );

  logVerbose(`Manifest saved to ${path.relative(cwd, typeOutputFilepath)}`);

  const readmeChanged = await writeIfChanged(
    path.join(inputDir, "README.md"),
    `# Icons

This directory contains SVG icons that are used by the app.

Everything in this directory is made into a sprite using \`npm run build:icons\`. This file will show in /public/icons/sprite.svg
`
  );

  if (spriteChanged || typesChanged || readmeChanged) {
    console.log(`Generated ${files.length} icons`);
  }
}

function iconName(file: string) {
  return file.replace(/\.svg$/, "").replace(/\\/g, "/");
}

/**
 * Creates a single SVG file that contains all the icons
 */
async function generateSvgSprite({
  files,
  inputDir,
  outputPath,
}: {
  files: string[];
  inputDir: string;
  outputPath: string;
}) {
  // Each SVG becomes a symbol and we wrap them all in a single SVG
  const symbols = await Promise.all(
    files.map(async (file) => {
      const input = await fsExtra.readFile(path.join(inputDir, file), "utf8");
      const root = parse(input);

      const svg = root.querySelector("svg");
      if (!svg) throw new Error("No SVG element found");

      svg.tagName = "symbol";
      svg.setAttribute("id", iconName(file));
      svg.removeAttribute("xmlns");
      svg.removeAttribute("xmlns:xlink");
      svg.removeAttribute("version");
      svg.removeAttribute("width");
      svg.removeAttribute("height");

      return svg.toString().trim();
    })
  );

  const output = [
    `<?xml version="1.0" encoding="UTF-8"?>`,
    `<!-- This file is generated by npm run build:icons -->`,
    `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="0" height="0">`,
    `<defs>`, // for semantics: https://developer.mozilla.org/en-US/docs/Web/SVG/Element/defs
    ...symbols,
    `</defs>`,
    `</svg>`,
    "", // trailing newline
  ].join("\n");

  return writeIfChanged(outputPath, output);
}

async function writeIfChanged(filepath: string, newContent: string) {
  const currentContent = await fsExtra
    .readFile(filepath, "utf8")
    .catch(() => "");
  if (currentContent === newContent) return false;
  await fsExtra.writeFile(filepath, newContent, "utf8");
  return true;
}

```
Next, we want to add the command to run this script to out `package.json`.

```json
  "scripts": {
    //...
    "build:icons": "npx tsx ./build-icons.mts"
  },
```
Now if we run `npm run build:icons`, you should see a `sprite.svg` file appear in `/public/icons`. It will also create a `name.d.ts` file in `types`. This is for TypeScript and helps us ensure we are referencing the correct icon.

#### 3. Using SVG Sprites in NextJs

We can begin to use our icons within an SVG like below, where `name` is the icon name. If you used subfolder in the icon folder, the name will be `folder/icon` such as `radix/camera`.

```tsx
    <svg {...props}>
      <use href={`/icons/sprite.svg}#${name}`} />
    </svg>
```

To create a reusable, typesafe and extensible Icon component, we can do something like this:

```tsx
import { type SVGProps } from "react";
import { cn } from "@/lib/cn";
import { type IconName } from "@/types/name";

export { IconName };

export function Icon({
  name,
  childClassName,
  className,
  children,
  ...props
}: SVGProps<SVGSVGElement> & {
  name: IconName;
  childClassName?: string;
}) {
  if (children) {
    return (
      <span
        className={cn(`inline-flex items-center font gap-1.5`, childClassName)}
      >
        <Icon name={name} className={className} {...props} />
        {children}
      </span>
    );
  }
  return (
    <svg
      {...props}
      className={cn("inline self-center w-[1em] h-[1em]", className)}
    >
      <use href={`./icons/sprite.svg#${name}`} />
    </svg>
  );
}

```

This component uses the `cn` tailwind helper function. If you do not already have it you can create that in `lib/cn.ts`

```ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
 
export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}
```
Now to use our Icon component, we simply do:

```tsx
<Icon className="h-4 w-4" name="radix/camera" />

```

Where name is the icon name. If you want the icon to appear to the left of text and be aligned, the component above can do that for you if you pass the text in as a child:
```jsx
<Icon className="h-4 w-4" name="radix/pencil-1" childClassName="text-red-600">
  Auto aligned text, with icon on left
</Icon>
```
Note that you can pass in classNames to the child element using `childClassName`, or just use your own nested element.

#### 4. Preload the sprite

To repload the sprite so its loaded and ready to use on page load, we need to create a client component for the NextJs App Router, called `preload-resources.tsx`

```tsx
"use client";

import ReactDOM from "react-dom";

export function PreloadResources() {
  ReactDOM.preload("/icons/sprite.svg", {
    as: "image",
  });

  return null;
}
```

Then we want to use this at our top most `layout.tsx`
```tsx
    <html lang="en">
      <PreloadResources />
```

#### 5. Sly configuration

If you have used sly to help with creating the SVG files, here is my example configuration for Lucide and Radix Icons.

This will run the build script for you after adding icons.

```json
{
  "$schema": "https://sly-cli.fly.dev/registry/config.json",
  "libraries": [
    {
      "name": "@radix-ui/icons",
      "directory": "./other/svg/radix",
      "postinstall": [
        "pnpm",
        "run",
        "build:icons"
      ],
      "transformers": []
    },
    {
      "name": "lucide-icons",
      "directory": "./other/svg/lucide",
      "postinstall": [
        "pnpm",
        "run",
        "build:icons"
      ],
      "transformers": []
    }
  ]
}
```

## Wrapping Up
Hopefully the above helps you get started with using SVG sprites in NextJs and React. Its a great approach with good performance compared to alternatives. You can view the example repo, as well as the components and scripts [here](https://github.com/jolbol1/nextjs-svg-sprites/tree/main)

One drawback to sprites is you don't typically install a library of icons and then use them like regular components, we have to use the build script. Further, there is no "tree-shaking" for sprites, all the icons you add will be added to it. So make sure you only add the ones you use.