Why an icon library doesn't have to bloat your bundle
- tree-shaking
- bundle-size
- react
- performance
- geoicons
Tree-shaking is why a GeoIcons install can hold 422 icons, the count at the time of this post and still growing, while your app ships three. When you write:
import { Us } from '@geoicons/react/countries'your bundler includes the Us component and drops the rest. You pay for what you import, not for what the package contains.
That sentence hides a lot of moving parts. Bundlers only drop code when the library is built to let them, and icon libraries are one of the easiest places to get it wrong.
Key takeaways
- Tree-shaking is dead-code elimination driven by static analysis of ES module imports.
- It works only when a library uses named exports, ships ES modules, and marks itself side-effect free.
- A central registry or
<Icon name="us" />lookup defeats it: the bundler cannot tell which icons you use, so it keeps all of them. - GeoIcons gives every icon its own named export and no runtime registry, so unused icons never reach your bundle.
What tree-shaking does
Tree-shaking is dead-code elimination for JavaScript modules. Bundlers like webpack, Rollup, esbuild, and Vite read the static import and export statements in your code, build a graph of what actually gets used, and leave everything else out of the final bundle.
The word "static" is the load-bearing part. ES modules declare their imports at the top level with literal names, so a bundler can decide at build time which exports are reachable without running your code. Here you reach one export:
import { Us } from '@geoicons/react/countries';The bundler follows that edge to the Us component, marks it as used, and never marks Fr, Jp, or the other 419. Unmarked exports are unreachable, so they get pruned. Three icons in, three icons out:
Two conditions have to hold for this to work. The library must ship real ES modules, not a single CommonJS blob, because require() is dynamic and cannot be statically analyzed. And the library must promise it has no import-time side effects, so the bundler knows that dropping an unused module changes nothing. Miss either one and the bundler plays it safe by keeping code it cannot prove is dead.
Why icon libraries are where it breaks
Icon sets are large by nature: hundreds or thousands of small components that most apps use a handful of. That makes them the highest-leverage place for tree-shaking, and also the easiest place to accidentally turn it off.
The usual culprit is a convenience API. A library exposes a single component and a string prop:
// The convenient-looking API that ships everything
import { Icon } from 'some-icons';
<Icon name="us" />
<Icon name="fr" />That name prop has to resolve to a component at runtime, which means the library keeps a lookup table somewhere:
// Inside the library
import * as flags from './all-flags';
const registry = { us: flags.Us, fr: flags.Fr, jp: flags.Jp /* ...419 more */ };
export function Icon({ name }) {
const Svg = registry[name];
return <Svg />;
}The bundler cannot know that your app only passes "us" and "fr". The value could come from a variable, a prop, or an API response. To keep the code correct, it has to retain every icon the registry can reach. You imported one component and shipped all 422.
The same failure hides in a few other shapes:
- A barrel file that re-exports everything and is imported as a namespace:
import * as Icons from 'some-icons'. - A dynamic member access:
Icons[name], wherenameis not a literal. - A package published only as CommonJS, so
importcannot be analyzed at all.
Each one asks the bundler a question it cannot answer at build time, and the safe answer is always "keep it."
How GeoIcons is built to stay shakeable
GeoIcons has no <Icon name="..."> API and no central registry. Each icon is its own named export in an ES module, imported by a name your bundler can read literally:
import { Us, Fr, Jp } from '@geoicons/react/countries';There is no lookup table for the bundler to get stuck on. The reference to Us is a static edge to exactly one component, so everything you never name is unreachable and gets pruned. This is also why the library has no string-keyed API: adding one would reintroduce the exact registry that breaks pruning.
The package ships ES modules and marks itself side-effect free, so bundlers are allowed to drop unused icon modules instead of keeping them just in case. Category subpaths like @geoicons/react/countries keep the graph even tighter: you reach into the countries entry point rather than a root barrel that touches every category at once.
The tradeoff is explicit imports. There is no <Icon name={dynamicValue} /> escape hatch, so a dynamic icon whose identity is known only at runtime needs you to build your own small map of just the icons you use:
import { Us, Fr, Jp } from '@geoicons/react/countries';
// You control this map, so it lists only what you import.
const byCode = { us: Us, fr: Fr, jp: Jp };
export function CountryFlag({ code }: { code: keyof typeof byCode }) {
const Icon = byCode[code];
return <Icon aria-hidden="true" />;
}That map holds three components because you wrote three. The bundler still sees three static imports at the top and prunes the rest. You get a runtime lookup where you need one, without dragging in the whole catalog.
See it in your own bundle
Do not take the claim on faith. Measure it. Add a bundle analyzer and look at what actually ships.
For a Next.js app:
npm i -D @next/bundle-analyzer// next.config.ts
import withBundleAnalyzer from '@next/bundle-analyzer';
const analyzer = withBundleAnalyzer({ enabled: process.env.ANALYZE === 'true' });
export default analyzer({ /* your config */ });ANALYZE=true npm run buildFor a Vite app, rollup-plugin-visualizer gives you the same treemap. Import three icons, build, and confirm that only three icon modules appear in the report. Then import a fourth and watch exactly one module join them. That linear growth, one module per import, is the signal that tree-shaking is working. If instead the whole icon set shows up after importing one, something in the chain (a barrel, a registry, a CommonJS build) has defeated it.
Ways to accidentally defeat it
Even with a shakeable library, your own code can force the bundler to keep more than you use. The common mistakes and their fixes:
- Namespace imports.
import * as Icons from '@geoicons/react/countries'thenIcons.Uspulls the namespace object, which references every export. Import the named members you need instead. - Dynamic access on the whole set.
Icons[code]over a namespace import is the registry antipattern in your own app. Build a small explicit map of only the icons you use, as shown above. - Re-export barrels you own. A local
icons.tsthat re-exports everything and gets imported wholesale can undo the library's work. Import from the source subpath directly.
None of these are exotic. They creep in through a shared helper or a "cleaner" import, and a bundle analyzer catches them in one build.
Why the bytes matter
Every kilobyte of JavaScript is paid for three times. It travels over the network, it gets parsed and compiled by the engine, and it runs. Parse and compile time scales with the amount of script, and on a mid-range phone that cost is far higher than on a developer laptop. Shipping 400 icons a user never sees delays the moment the page becomes interactive, for no visible benefit.
Tree-shaking removes that tax without asking you to manage it by hand. You do not maintain a manual subset of the library or split it into micro-packages. You import the icon you need at the point you need it, and the build keeps the bundle proportional to your actual usage. A library of 422 icons costs the same as a library of 12 if you only import 12. GeoIcons is built on that principle. Browse the full set to see what you would import.
FAQ
- Does importing one icon include all 422?
- No. Each icon is a separate named export, so a bundler that supports tree-shaking includes only the icons you import and drops the rest. Importing one component adds one module to your bundle, and the icons you never reference never ship.
- Why does an <Icon name="us" /> API hurt bundle size?
- A name prop resolves to a component at runtime through a lookup table that references every icon. The bundler cannot tell which names you pass, so it keeps all of them. GeoIcons instead exposes each icon as its own import, which stays shakeable.
- How do I confirm tree-shaking works in my app?
- Run a bundle analyzer such as @next/bundle-analyzer or rollup-plugin-visualizer, then build. Import three icons and check that only three icon modules appear in the report. Import a fourth and exactly one module should join them.
Get started
Install the package and import your first icon. Then run the analyzer and confirm the bundle only grows by what you use:
npm i @geoicons/reactimport { Us } from '@geoicons/react/countries';
export default function App() {
return <Us aria-label="United States" />;
}The full prop and import reference lives in the docs. New to the library? Start with the launch post.