There are four ways to put an SVG icon in a React app, and they are not equivalent. Which you should use depends on how many icons you have, whether they need to be selected at runtime, and whether you are rendering on the server.
The short answer
Use the official package for your icon set (lucide-react, @tabler/icons-react, @phosphor-icons/react) for anything real. Hand-write components only for a handful of custom icons. Use a sprite when icons are chosen at runtime from data. Avoid <img> for interface icons, because you lose colour control.
JSX attribute traps
Pasting raw SVG into JSX fails, and the error messages are not always clear about why. Four things need converting.
| HTML | JSX |
|---|---|
stroke-width | strokeWidth |
stroke-linecap | strokeLinecap |
fill-rule, clip-rule | fillRule, clipRule |
class | className |
xlink:href | xlinkHref, or just href |
Two exceptions worth remembering because they break the pattern: viewBox keeps its capital B and is not converted, and data and aria attributes stay hyphenated. So aria-hidden stays as it is.
Inline styles also change shape. style="fill: red" becomes style={{ fill: 'red' }}, taking an object rather than a string. In practice you should be using currentColor and CSS classes instead of inline styles anyway.
Writing an icon component properly
A good icon component does three things: defaults its size, forwards unknown props, and hides itself from assistive technology by default.
export function SearchIcon({ size = 20, ...props }) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
{...props}
>
<circle cx="11" cy="11" r="7" />
<path d="M21 21l-4.3-4.3" />
</svg>
);
}
The {...props} spread is what makes the component usable rather than merely functional. It lets a caller pass className, onClick, data-testid or an aria-label without you having to anticipate each one. Put it last so callers can override your defaults, including aria-hidden when the icon does need to be announced.
Keeping stroke="currentColor" rather than accepting a color prop is deliberate. Colour then comes from CSS, which means hover, focus, disabled and dark mode all work with no prop threading at all.
Using an icon package
import { Search, Settings, ChevronDown } from 'lucide-react';
<Search size={20} />
<Settings size={20} strokeWidth={1.5} />
<ChevronDown size={16} className="text-gray-400" />
Two props worth knowing about in lucide-react. strokeWidth does what you expect. absoluteStrokeWidth keeps the rendered stroke at a fixed pixel width regardless of icon size, which solves the problem where a 2px stroke looks spindly at 48px and heavy at 14px. Without it, browsers scale stroke proportionally, which is visually the wrong behaviour. Background in icon sizes.
Phosphor's React package offers a context provider, which is genuinely valuable with a multi-weight set:
<IconContext.Provider value={{ size: 20, weight: 'regular' }}>
<App />
</IconContext.Provider>
Every icon below inherits those defaults, so switching the whole product from regular to bold is one line. See Phosphor.
Bundle size and tree shaking
The fear is that importing from a 5,000 icon package ships 5,000 icons. With a modern bundler and named imports, it does not: each icon is a separate module and unused ones are eliminated.
What breaks tree shaking, and it is worth checking because it silently costs hundreds of kilobytes:
- Namespace imports.
import * as Icons from 'lucide-react'pulls in everything. Always use named imports. - Building a lookup object. Importing fifty icons into a map so you can select by string reference forces all fifty into the bundle, whether or not any are rendered.
- CommonJS builds. Tree shaking needs ES modules. If your bundler resolves the package's CJS entry, nothing is eliminated.
- Re-exporting through a barrel file without
sideEffects: falseconfigured, which can defeat elimination depending on your setup.
The practical check: build, then look at your bundle analyser output for the icon package. If it is measured in hundreds of kilobytes rather than a few, one of the above is happening.
Dynamic icons chosen at runtime
This is the case where components stop being the right tool. If your icon name comes from an API, a CMS or a config file, you cannot statically import it, and every workaround has a cost.
The explicit map. Import only the icons you actually support and map them:
import { Search, Settings, User } from 'lucide-react';
const ICONS = { search: Search, settings: Settings, user: User };
function Icon({ name, ...props }) {
const Cmp = ICONS[name];
return Cmp ? <Cmp {...props} /> : null;
}
Honest and predictable. Every icon in the map ships, so keep the map to icons you genuinely use.
The sprite. For a genuinely open-ended set, a sprite is better than any React solution:
function Icon({ name, size = 20, ...props }) {
return (
<svg width={size} height={size} aria-hidden="true" {...props}>
<use href={`/icons.svg#${name}`} />
</svg>
);
}
One cached request covers every icon, the JS bundle carries none of them, and currentColor still works. The trade is a build step to generate the sprite, and the sprite must be same-origin because <use> is blocked cross-origin.
What to avoid is dynamic import() per icon. It works, and it produces a network request per icon and visible pop-in as each resolves.
SVGR and importing .svg files
Most React toolchains let you import an SVG file as a component, usually via SVGR:
import SearchIcon from './icons/search.svg?react'; // Vite
import { ReactComponent as SearchIcon } from './icons/search.svg'; // CRA
Convenient for custom icons that live in your repo. Three things to configure or you will hit problems later:
- Turn on SVGO in the SVGR config, but configure it to preserve
viewBoxandcurrentColor. SVGO's defaults strip the first and can inline the second, which breaks scaling and theming. See optimising SVG. - Enable ID prefixing. Multi-colour SVGs contain gradient and clip path IDs; two icons with the same ID on one page collide and render wrongly. This bug is intermittent and unpleasant to diagnose.
- Check the syntax for your bundler. The import form differs between Vite, Next.js, Create React App and Webpack, and the wrong one fails confusingly.
Server components and hydration
Icons are static markup with no state, no effects and no event handlers, which makes them ideal server components in Next.js App Router and similar frameworks. Leave them out of your "use client" boundary and they render on the server with zero JavaScript shipped for them.
The common mistake is putting "use client" at the top of a shared icon module. Every icon then becomes a client component, and the whole set is pulled into the client bundle even though not one of them needs interactivity. Icon components should almost never be client components; the interactive thing is the button around the icon, not the icon.
Hydration mismatches with icons are rare, because the markup is deterministic. If you see one, look for a randomly generated ID, which some SVG tooling adds for gradients and clip paths, generating a different value on server and client. Use a deterministic prefix instead.
Accessibility in React
The rules are the same as plain HTML, but the component boundary makes it easy to get wrong in a systematic way.
{/* Icon-only button: name the BUTTON, hide the icon */}
<button aria-label="Search">
<SearchIcon aria-hidden="true" />
</button>
{/* Icon with visible text: hide the icon */}
<button>
<SearchIcon aria-hidden="true" /> Search
</button>
{/* Icon carrying meaning alone */}
<StatusIcon role="img" aria-label="Connected" />
Default aria-hidden="true" in your icon components, since the large majority of icons are decorative or paired with text. The spread lets callers override it in the rare case where the icon itself carries the meaning. Full patterns in accessible icons.