DEV Community

Cover image for Turbopack: A Better Way to Inline SVG in Next.js 16
Vitaliy Potapov
Vitaliy Potapov

Posted on

Turbopack: A Better Way to Inline SVG in Next.js 16

Next.js 16 enabled Turbopack as a default bundler. It is fast, modern, and noticeably improves the DX in many areas.

But when I started adding SVG icons to my project, I realized the common options did not cover my needs:

  • I wanted icons to be inlined, so they display instantly without an extra network request.
  • I wanted to avoid the SVG-in-JS performance penalty (more on this later).
  • I wanted to customize icon color via CSS.
  • And everything had to be compatible with Turbopack, not just Webpack.

I tried the popular SVG approaches for Next.js apps: built-in <Image />, SVGR, SVG sprites. They are all well-known and widely used, but none of them fully matched my requirements. Let’s look at why they fall short and how I built a custom Turbopack loader that solved the issue.

Why Existing Approaches Fall Short

Let me quickly show why the most common ways to handle SVGs in Next.js fall short for SVG icons.

1. Next <Image />

Next.js has excellent built-in support for importing SVGs as static images. The usage is straightforward:

import Image from 'next/image';
import myIcon from './icon.svg';

export default function Page() {
  return <Image src={myIcon} alt="my icon" />;
}
Enter fullscreen mode Exit fullscreen mode

The imported myIcon object looks like this:

{
  src: "/_next/static/media/icon.682156e7.svg",
  width: 24,
  height: 24,
}
Enter fullscreen mode Exit fullscreen mode

In HTML it appears as <img> tag:

Image in HTML

This has two big benefits:

  • ✅ Turbopack automatically copies the original SVG to the output directory with a hashed filename, so the file is immutable and cacheable.
  • ✅ It also extracts the intrinsic width and height, ensuring proper layout without shifts.

But for icons specifically, this approach has two major drawbacks:

  • ❌ It generates a separate HTTP request for every icon, which means your icons may not appear instantly.
  • ❌ You can’t change the icon color via CSS, since the SVG isn’t inlined.

Great for logos and large illustrations. Not great for small UI icons.

2. SVGR (@svgr/webpack)

SVGR converts an SVG into a React component. Turbopack supports this loader, and it is a pretty popular pattern:

import Icon from './icon.svg';

export default function Page() {
  return <Icon className="text-red-500" />;
}
Enter fullscreen mode Exit fullscreen mode

In HTML it inlines SVG content directly into the DOM:

SVGR content

Pros & cons:

  • ✅ You can fully customize the icon via CSS.
  • ✅ Since the SVG is inlined, it renders instantly.
  • ❌ This approach moves the entire SVG markup into your JavaScript bundle. For small icons this may look harmless, but it adds up quickly, especially in apps with dozens or hundreds of icons.

There’s a great deep-dive on this topic: Breaking Up with SVG-in-JS.

In short: extra DOM nodes, extra JS, extra parsing, and none of it is necessary if your SVG is just a static asset.

Due to this performance cost, I stopped using SVGR in my projects.

3. SVG Sprite Maps

Sprite maps combine multiple SVGs into a single <svg> file, and individual icons are referenced with the <use> tag.

Example SVG sprite:

<svg>
  <symbol id="icon1">
    <path d="..." />
  </symbol>
  ...
</svg>
Enter fullscreen mode Exit fullscreen mode

Usage in JSX:

return <svg><use href="/sprite.svg#icon1" /></svg>;
Enter fullscreen mode Exit fullscreen mode

If you want a detailed explanation of the sprite technique, here is a great article: Use svg sprite icons in React.

In the context of Turbopack, the real question is how to generate the sprite effectively. There are two ways:

a) Pre-build script

A script crawls your icon directory and generates one giant sprite.

Pros & cons:

  • ✅ Works with any framework or bundler
  • ❌ Includes every icon, not just the ones you import
  • ❌ Requires an extra script + watcher integration → not ideal DX

b) Loader-based sprite generation

A loader collects only the SVGs you actually import and builds a sprite automatically.

Pros & cons:

  • ✅ Only includes used icons
  • ❌ Not compatible with Turbopack: currently Turbopack loaders can produce only JavaScript output and do not support this.emitFile() method (unlike webpack).

Extra issue: Safari doesn’t render sprite icons that contain SVG filters (bug report). I tested this and confirmed the issue on Safari 26.1. Many of my icons rely on filters, so this made sprite maps unusable for me.

Solution: Inline Small SVGs as Data URI

Finally, I ended up looking at another option: what if small SVG icons were just inlined directly as data URIs?

Ideally, the imported SVG would provide the same object as an external image, but with a data URI in src:

import myIcon from './icon.svg';

/*
{
  src: "data:image/svg+xml,...",
  width: 32,
  height: 32
}
*/
Enter fullscreen mode Exit fullscreen mode

For icons, this has a few natural advantages:

  • The icon appears instantly.
  • The SVG stays as a static asset: no extra DOM nodes, no SVG-in-JS overhead.
  • The image's width and height preserve the intrinsic size.
  • Since it behaves like a normal image import, it can be passed to the <Image /> component:
import Image from 'next/image';
import myIcon from './icon.svg';

export default function Page() {
  return <Image src={myIcon} alt="my icon" />;
}
Enter fullscreen mode Exit fullscreen mode

But none of the existing loaders supported this pattern, so I decided to build my own.

Building a Custom Turbopack SVG Loader

The loader should perform three things:

  1. convert SVG content into a compact data URI
  2. extract SVG’s intrinsic width and height for correct default sizing
  3. return these values as an object { src, width, height }

The good news is that all of this can be done with a few existing packages:

  • svgo to optimize the SVG markup
  • mini-svg-data-uri to convert it into a very compact data URL
  • image-size to extract intrinsic dimensions (the same library Next.js uses internally)

Because of that, the loader code turned out to be surprisingly small:

// inline-svg-loader.js
const { optimize } = require("svgo");
const svgToMiniDataURI = require("mini-svg-data-uri");
const { imageSize } = require("image-size");

module.exports = function (content) {
  this.cacheable?.();

  const optimized = optimize(content);
  const src = svgToMiniDataURI(optimized.data);
  const { width, height } = imageSize(Buffer.from(content));
  const result = { src, width, height };

  return `export default ${JSON.stringify(result)};`;
};
Enter fullscreen mode Exit fullscreen mode

It uses CommonJS syntax, because Turbopack does not support ESM loaders yet.

Then, I added the loader to my next.config.js so Turbopack applies it to .svg imports:

const nextConfig = {
  turbopack: {
    rules: {
      '*.svg': {
        loaders: ['./inline-svg-loader.js'],
        as: '*.js',
      },
    },
  },
};

export default nextConfig;
Enter fullscreen mode Exit fullscreen mode

Given this setup, my Next.js project correctly displayed checkmark SVG icon inlined into the <img> tag:

Icon in browser
Icon in HTML

Full data URI is collapsed by the devtools.

However, there are two problems that still need to be resolved:

  1. Now every SVG is inlined, including large images.
  2. How to change the color of the inlined SVG?

Problem 1: Conditional Inlining

Ideally, I wanted a setup where:

  • small SVGs are inlined as data URIs
  • large SVGs fall back to the built-in Next.js behavior
  • both produce the same {src, width, height} object compatible with <Image /> component

Fortunately, Next.js 16 shipped exactly what I need: Turbopack condition rules. These rules allow loaders to conditionally apply only to files matching different criteria.

The key part is the content condition, which checks the entire file body. Since it accepts a RegExp, it’s possible to match files by approximate size. For example, this pattern matches files up to around 4 Kb:

/^[\s\S]{0,4000}$/
Enter fullscreen mode Exit fullscreen mode

It uses [\s\S] to match any character including newlines.

Using this, I updated the Turbopack configuration to only apply the inline loader to small SVGs:

const nextConfig = {
  turbopack: {
    rules: {
      '*.svg': {
        loaders: ['./inline-svg-loader.js'],
        condition: {
          content: /^[\s\S]{0,4000}$/, // Inline SVGs smaller than ~4Kb
        },
        as: '*.js',
      },
    },
  },
};

export default nextConfig;
Enter fullscreen mode Exit fullscreen mode

To illustrate conditional inlining, I created a page with two SVG images:

  1. a checkmark icon (436 bytes)
  2. new W3C logo (42 Kb)

Next.js correctly renders both images: the first one is inlined and the second is external:

Two images rendered
Two images in HTML

A nice use-case for Turbopack's built-in capabilities!

Hopefully, Turbopack will add a dedicated condition for filesize in the future. I've opened a feature request for that in the Next.js repo.

Problem 2: Customizing Icon Color

The approach to coloring an SVG depends on how much customization you need. If you want to style different parts of the SVG independently, it must be rendered as DOM nodes anyway. But most of my icons are monochrome, and the only requirement is setting a single color via CSS. For this case, there is a clean workaround: use the SVG as a mask.

Instead of modifying the SVG itself, the color is set to the element’s background and the SVG defines the visible shape. The result is a fully color-customizable icon that still behaves like a regular image asset.

The masking setup:

  • render the <img> tag with src set to an empty image
  • set width and height attributes to the intrinsic values for SVG
  • set the CSS mask property to the actual icon data URI
  • set background-color: currentcolor to make the icon color customizable

I wrapped it into the universal Icon.tsx component:

/**
 * A component for rendering mono-color SVG icons using the current text color.
 */
import { type ComponentProps } from 'react';
import { type StaticImageData } from 'next/image';

type IconProps = Omit<ComponentProps<'img'>, 'src'> & {
  src: StaticImageData;
};

const EMPTY_SVG = `data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'/%3E`;

export default function Icon({ src, width, height, style, ...props }: IconProps) {
  return (
    <img
      width={width ?? src.width}
      height={height ?? src.height}
      src={EMPTY_SVG}
      style={{
        ...style,
        backgroundColor: 'currentcolor',
        mask: `url("${src.src}") no-repeat center / contain`,
      }}
      {...props}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

With this setup, customizing the icon color becomes as simple as writing normal CSS:

import Icon from './Icon';
import myIcon from './icon.svg';

// Set color with style
return <Icon src={myIcon} style={{ color: 'red' }} />;

// Set color with Tailwind
return <Icon src={myIcon} className="text-green-600" />;

// Set both size and color
return <Icon src={myIcon} width={64} height={64} className="text-blue-600" />;
Enter fullscreen mode Exit fullscreen mode

Output:

Three icons

This method works well for any single-color SVG icon. Multi-color icons could still be rendered normally with <Image /> component.

Packaging the Loader

Although the inline loader is only a few lines of code, I quickly found myself wanting to reuse it in multiple projects. Instead of copying the file around, I built a small npm package that includes all required dependencies and works directly with Turbopack’s configuration rules.

The package lives here: turbopack-inline-svg-loader

You can install it and pass the loader name to the Turbopack configuration:

const nextConfig = {
  turbopack: {
    rules: {
      '*.svg': {
        loaders: ['turbopack-inline-svg-loader'],
        condition: {
          content: /^[\s\S]{0,4000}$/, // Inline SVGs smaller than ~4Kb
        },
        as: '*.js',
      },
    },
  },
};

export default nextConfig;
Enter fullscreen mode Exit fullscreen mode

This keeps the project setup clean and allows the loader to be shared and updated like any other dependency.

Conclusion

The combination of a custom loader, Turbopack’s condition rules and CSS masking provided a flexible SVG workflow in my Next.js projects:

  • small icons are inlined and render instantly.
  • larger SVGs fallback to the default Next.js loader and remain external.
  • both imports work seamlessly with the <Image /> component.
  • monochrome icons can be styled via CSS.

For use cases requiring deeper control of SVG internals, SVGR or inlined markup is still the right choice. But for many other projects, I believe this approach should be a practical and efficient alternative. Feel free to share your feedback!

Top comments (0)