Published on

Using SVG Sprites with NextJS 13

Authors

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, as well as a great article explaining the different methods and their trade-offs.

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

Credit


The code I will be using is slightly modified from Kent C. Dodds Epic Stack implementation. His is for Remix.js. I have modified a few parts to make it work on NextJs.

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

npx @sly-cli/sly add @radix-ui/icons camera card-stack --yes --directory ./other/svg/radix

You can also use:

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:

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 to script on GitHub, or copy and paste below

build-icons.mts
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.

package.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.

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

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

components/icon.tsx
import { type SVGProps } from "react"
 
import { type IconName } from "@/types/name"
 
import { cn } from "@/lib/cn"
 
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

cn.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:

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

<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

preload-resources.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

layout.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.

sly.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

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.