Microfrontends usage is becoming popular nowadays. Teams goal to make applications work faster, and then move from a monolith architecture. Everything is simple - they expect smaller bundles, independent releases, and faster delivery. And transition usually gives that.
But the transition to microfrontends involves optimizations: it is necessary to identify what will be separated out, get rid of dependencies and understand how communication with the monolith will be implemented. It’s a real volume of tasks, and small issues, such as the use of SVG sprites, often go unnoticed.
New applications are often built with a focus on future scalability and microfronted architecture, but older applications that were designed as monoliths often have a system where icons are stored in a location/package and assembled into a single sprite. And when you migrate, you may encounter that your microfrontend still pulls in the whole sprite — hundreds of SVG symbols that page never actually needs.
My Production Case
In the production scenario that pushed me to create some solution, our shared icon set contained 319 SVG icons. But one microfrontend actually used 38.
The remaining 281 icons were transferred and still bundled into every microfrontend that would never render them.
Nothing was broken. But from optimization perspective it’s wasteful once you looked at how much was being shipped across every microfrontend. It’s the kind of thing you don’t really notice until you look at what actually gets shipped.
We’re loading more than we actually use — so it felt like we should fix that
I tried to find solutions in the already created packages, but nothing appealed to me.
The real issue is that there is no automatic connection between what the code imports and what the final sprite contains.
The Core Idea
I deside to guide a simple principle:
If an icon is not referenced in the codebase, it should not exist in the generated sprite.
Instead of maintaining configuration by hand and generation changes, so as not to break what monolith has already worked with, the build should derive the result directly from the source code.
That's the idea behind @mf-toolkit/sprite-plugin — a build-time tool that scans the application, detects which icons are actually imported, matches them to SVG files, optimizes them, and generates a minimal sprite containing only the required symbols. Tree-shaking, but for SVG sprites. If icons are already React/Vue components, tree-shaking handles this automatically but in case your have single core sprite, such tool will be helpfull
Architecture
The flow is intentionally simple — four stages, each doing one thing well:
How @mf-toolkit/sprite-plugin works — source code and icons folder feed into the build step, which scans imports, matches SVG files, and generates a minimal sprite
The goal was never to build a clever compiler experiment. It was to build something a real team could add to an existing microfrontend in a few lines of config — without changing how anyone writes application code.
The minimal setup proves the point:
new MfSpriteWebpackPlugin({
iconsDir: './src/assets/icons',
sourceDirs: ['./src'],
importPattern: /@my-ui\/icons\/(.+)/,
output: './src/generated/sprite.ts',
});
Four lines. The complexity belongs inside the tool, not in the consumer's build config. And i’v made some configurations if you using Rollup and Vite. Becasue it’s open source code, here i’v described some steps how it works, maybe it helps you to make your own tool.
Stage 1: Analyze the Imports, Not the UI
The system starts from the only one reliable signal - imports. If a codebase imports an icon, that's evidence of usage. If it never imports it, that icon should not be shipped.
This sounds straightforward until you look at real projects. Icon usage doesn't appear in just one neat form:
// Static imports
import { CartIcon } from '@ui/icons/cart';
import CartIcon from '@ui/icons/cart';
// Re-exports
export { CartIcon } from '@ui/icons/cart';
// Dynamic imports
const CartIcon = await import('@ui/icons/cart');
import('@ui/icons/cart').then(({ CartIcon }) => /* ... */);
// React.lazy
const CartIcon = React.lazy(() =>
import('@ui/Icon/ui').then(m => ({ default: m.CartIcon }))
);
// CommonJS
const CartIcon = require('@ui/icons/cart');
All of these patterns need to be detected reliably. Missing even one means a missing icon in production.
The Parser Trade-off That Shaped Everything
The engineering decision in this project was how to parse these imports.
I had a choice either to use a ready-made AST parser, it gives full accuracy in determining our cases, but it would also give us an additional dependency (@babel/parser adds ~5 MB) (@babel/parser adds ~5 MB) and install friction for every microfrontend that adopts the tool, so the package weight and its dependencies are of great importance to the teams.
Because of that, decision was to start with regex-based analysis by default, and make AST parsing optional.
The default regex analyzer has zero extra dependencies. It makes entire package is really small, but it still covers the real-world patterns that matter.
The implementation checks the source code character-by-character to skip comments while preserving string content — handling escape sequences, block comments, and line comments correctly before any pattern matching begins. Then it normalizes multiline imports to single lines, and runs targeted patterns against each line.
For dynamic imports, the analyzer handles three distinct patterns. Take this common React.lazy pattern:
import('@ui/Icon/ui').then(m => ({ default: m.ChevronRight }))
The parser first identifies the .then() callback parameter name (m), then dynamically constructs a regex to find member accesses like m.ChevronRight — only capturing identifiers starting with uppercase to avoid false positives on utility calls.
The proof: tested against the same production codebase with 319 available icons, and the regex analyzer matched the exactly the same 38 icons as the Babel-based AST parser.
But I also wanted to give teams freedom, in most real projects, typescript or @babel/parser is already installed. Because of that i’v made some optionals for AST-based parsers usage that are loaded dynamically: if the dependency exists, use it, if not, you still have regex by default. But the parser layer is designed as a pluggable interface — adding support for a new parser (say, swc or oxc as they mature) is a matter of implementing one function, not rewriting the pipeline.
Stage 2: Map Code-Level Usage to Physical SVG Files
Once imports are collected, the next problem is naming.
Real design systems are rarely uniform. An import path says CartIcon, but the file on disk is cart-icon.svg. Another import says Coupon2, but the asset is coupon-2.svg.
The plugin resolves this through a multi-strategy name matching pipeline:
"ChevronRight" → ["chevronright", "chevron-right"] → matches chevron-right.svg
"Coupon2" → ["coupon2", "coupon-2"] → matches coupon-2.svg
"HTTPServer" → ["httpserver", "http-server"] → matches http-server.svg
Under the hood, the PascalCase-to-kebab-case conversion applies three regex passes to handle different boundary types: lowercase-to-uppercase (cartIcon → cart-Icon), consecutive capitals (HTTPServer → HTTP-Server), and letter-to-digit (icon2 → icon-2).
For projects where icons live in subdirectories (e.g., ui/arrow.svg vs payment/arrow.svg), the plugin supports category-prefixed matching. When extractNamedImports is enabled:
import { Arrow } from '@ui/Icon/ui'; // → matches ui/arrow.svg
import { Arrow } from '@ui/Icon/payment'; // → matches payment/arrow.svg
Same icon name, different directories — no ambiguity. And if an icon is imported but no matching SVG file exists, the plugin logs a warning and continues. It won't break your build.
Stage 3: Optimize Before Combining
Selecting fewer icons is only half the optimization. The SVGs themselves still carry unnecessary weight.
Files exported from Figma, Sketch, or Illustrator often contain editor metadata, redundant width/height attributes, XML namespaces, empty groups, and hardcoded colors. The plugin goes thrue each SVG through SVGO with a tuned configuration (this configuration can be changed if you want to make something custom) — multipass optimization, dimension removal (using viewBox instead), and namespace cleanup.
One more thing - plugin replaces black color values with currentColor before running SVGO, not after. Why? Because SVGO's default preset removes redundant black fills as an optimization — if it runs first, we lose the chance to convert them to currentColor and the order matters.
The result: icons automatically inherit the text color of their parent element. Set color: red on the container — every icon turns red. No hardcoded values, no extra CSS.
The Edge Case That Breaks Naive Sprite Generators
Here's a problem that bites people in production. When multiple SVGs are merged into a single sprite, their internal IDs can collide.
Design tools love generating IDs like gradient1, clip0, mask1. Merge two icons that both define id="gradient1", and one of them silently references the other's gradient. The result? Corrupted icons that only appear broken when certain combinations load on the same page.
The plugin solves this by automatically prefixing all internal IDs per icon:
<!-- Before: both icons use id="grad1" — collision -->
<symbol id="cart"><circle fill="url(#grad1)" />...</symbol>
<symbol id="star"><rect fill="url(#grad1)" />...</symbol>
<!-- After: each icon gets a unique prefix -->
<symbol id="cart"><circle fill="url(#cart--grad1)" />...</symbol>
<symbol id="star"><rect fill="url(#star--grad1)" />...</symbol>
This covers id attributes, url(#...) references, href="#...", and xlink:href="#..." — all four reference types that SVG uses for internal linking. It's a small implementation detail, but it's the kind of thing that separates "works in demo" from "works in production."
Stage 4: Generate Code That's Easy to Consume
The output isn't just a file — it's a generated TypeScript module with a runtime injection function:
import { injectSprite } from './generated/sprite';
injectSprite();
One call at app startup. Then the application uses standard SVG <use> references as before. No API changes for product teams. No migration of existing icon components.
The generated injectSprite() is idempotent (safe to call multiple times — it injects only once) and SSR-compatible (checks for document before touching the DOM). The sprite is inserted as a hidden container at the top of <body> with a data-mf-sprite attribute for debugging.
This matters because adoption depends on integration cost. If the output is awkward to wire in or behaves differently across environments, teams postpone integration. The goal was to make it trivial.
Being Honest About Limits
One of the most underrated qualities in developer tooling is transparency about what it can't do.
Some import patterns are not statically analyzable. Namespace imports and wildcard re-exports fall into this category:
import * as Icons from '@ui/Icon/ui'; // Which icons are used? Can't know statically.
export * from '@ui/Icon/ui'; // Same problem.
Instead of silently ignoring these or guessing, the plugin emits clear warnings with file location and a suggested refactor:
[sprite] Namespace import is not statically analyzable: import * as Icons from '@ui/Icon/ui'
at src/components/App.tsx:3
Refactor to named imports: import { Icon1, Icon2 } from '@ui/Icon/ui'
Similarly, some design system libraries export lazy-loading wrappers (like PacmanLazy) that don't correspond to actual SVG files. These appear in the manifest's missing list — expected and safe to ignore.
The optional build manifest (sprite-manifest.json) makes this machine-readable:
{
"generatedAt": "2025-03-25T12:00:00.000Z",
"iconsCount": 34,
"missingCount": 3,
"icons": [
{ "name": "cart", "sources": ["src/components/Cart.tsx:5"] },
{ "name": "ui/arrow", "sources": ["src/components/Nav.tsx:2"] }
],
"missing": ["PacmanLazy", "SplitLazy"]
}
Plug it into CI to fail builds when too many icons are missing. Use it in dashboards. Debug why a particular icon is or isn't in the sprite. The data is there.
The Result
On the production case used to validate the approach, the numbers speak for themselves:
|
Metric |
Before |
After |
|---|---|---|
|
Icons in design system |
319 |
319 |
|
Icons shipped to the browser |
319 |
38 |
|
Icons excluded |
0 |
281 |
|
Icon payload reduction |
— |
88% |
But the icon count is only part of the story.
Each SVG also goes through SVGO optimization — metadata stripped, paths minified, dimensions removed, redundant attributes cleaned up. So the remaining 38 icons are individually smaller than their source files too. The total reduction in actual bytes transferred is even larger than the 88% symbol count suggests.
No manual curation. No configuration drift. Just a build step that looks at what the code actually uses and generates exactly that.
Where This Matters Most: App Shell Updates
There's one scenario where this optimization becomes especially critical — app shell updates.
In microfrontend architectures, the app shell is the outermost layer: the layout, navigation, shared components, and — yes — the icon sprite. When a team updates the app shell, every user who visits the site has to download the new version.
If your sprite contains 500 icons and you add one new icon to the design system, every user re-downloads all 500 — even though only 12 are used. The cache is invalidated for a file that's 97% waste.
With a usage-derived sprite, the app shell update only ships what's actually referenced. The payload is smaller, cache invalidation hurts less, and the update cycle stays lean. For products with millions of users, this difference compounds quickly.
The same logic applies to any scenario where shared assets are re-delivered — CI/CD deployments, CDN cache busts, A/B testing shells, or rolling out a new version across regions. The less unnecessary weight in the critical path, the faster the rollout lands.
More Than an Icon Problem
At the surface level, this project is about SVG sprites. I didn’t start with some big theory here. I just noticed that a microfrontend was shipping way more icons than it actually used, and that felt wrong enough to automate.
Honestly, this wasn’t something I set out to solve — I just ran into it while working on a microfrontend setup and ended up digging into it.
Microfrontends are supposed to make systems more modular. But if every app still drags around the full weight of shared assets, part of that modularity is organizational, not technical. Icons are just one example. The same pattern appears in translations, style layers, shared media, and other "global" resources that are available everywhere but needed only in fragments.
A lot of frontend optimization work is reactive. We notice something heavy in production, then try to patch around it. A better approach is to ask a structural question early:
What are we shipping by default that should really be derived from usage?
In this case, the answer was icons. Tomorrow, it could be something else. But the principle holds: if your frontend architecture is modular, your asset delivery should be modular too.
The package is open source on npm: @mf-toolkit/sprite-plugin
If you're dealing with a similar problem in your microfrontend setup — give it a try and open an issue if something doesn't work for your case.
