SVG Icons in Astro: The Complete Guide (Zero-JS, Iconify & lucide-react)
How to use SVG icons in Astro — static inline SVGs, astro-icon, @iconify/svelte in islands, lucide-react in React islands, and zero-JS patterns for maximum performance.
Astro’s island architecture changes how SVG icons should be used. Most icons in a static Astro page should ship with zero JavaScript — they’re layout, not behavior. Only icons inside interactive islands (React, Vue, Svelte components) need client-side code. Getting this right cuts your JavaScript bundle significantly.
This guide covers every approach from fully static to fully interactive, with performance tradeoffs at each level.
Astro’s icon performance model
| Approach | JS shipped | SSR-safe | Recommended for |
|---|---|---|---|
Inline SVG in .astro | 0 bytes | Yes | Static icons everywhere |
astro-icon package | 0 bytes | Yes | Large icon sets, clean DX |
lucide-react in React island | Only for that island | Yes | Interactive icon-heavy components |
@iconify/vue in Vue island | Only for that island | Yes | Vue islands |
| Iconify CDN API (img tag) | 0 bytes | Yes | One-off preview icons |
If an icon is decorative or informational (not part of an interactive component), use astro-icon or inline SVG. Shipping a React island just to render a static bell icon is a performance anti-pattern.
Option 1: Inline SVG (zero JS, zero dependencies)
The simplest approach for custom or one-off icons — paste the SVG directly into your .astro template.
---
// No imports needed
---
<button class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<circle cx="11" cy="11" r="8"/>
<path d="m21 21-4.35-4.35"/>
</svg>
Search
</button>
For reusable inline icons, create a small .astro component:
---
// src/components/icons/SearchIcon.astro
const { size = 20, class: className = '' } = Astro.props;
---
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class={className}
aria-hidden="true"
>
<circle cx="11" cy="11" r="8"/>
<path d="m21 21-4.35-4.35"/>
</svg>
---
import SearchIcon from '@/components/icons/SearchIcon.astro';
---
<SearchIcon size={18} class="text-gray-500" />
Option 2: astro-icon (recommended for large icon sets)
astro-icon is the standard icon solution for Astro. It integrates directly with Iconify, serving all 250,000+ icons with zero runtime JavaScript — icons are inlined as SVG during the build.
npm install astro-icon @iconify-json/lucide
# Install individual packs you need:
npm install @iconify-json/tabler @iconify-json/ph
// astro.config.mjs
import { defineConfig } from 'astro/config';
import icon from 'astro-icon';
export default defineConfig({
integrations: [icon()],
});
Usage
---
import { Icon } from 'astro-icon/components';
---
<!-- Format: pack:icon-name -->
<Icon name="lucide:search" class="w-5 h-5" />
<Icon name="tabler:dashboard" class="w-5 h-5" />
<Icon name="ph:heart-duotone" class="w-5 h-5 text-blue-500" />
Icons are resolved at build time and inlined as SVG — no CDN fetch, no JS bundle, no runtime cost.
Size and color control
<Icon name="lucide:settings" size={20} class="text-gray-600 dark:text-gray-400" />
The size prop sets width and height. Color inherits from currentColor via text-* Tailwind classes.
@iconify/json (~200MB) installs all icon packs. Install individual packs (@iconify-json/lucide) to keep node_modules lean. Astro only bundles icons you actually reference.
Option 3: lucide-react in React islands
For icon-heavy interactive components (dropdowns, comboboxes, animated panels), put the icon library inside the React island and let Astro handle the rest statically.
---
// A static Astro page — no JS for the surrounding layout
import CommandPalette from '@/components/CommandPalette';
---
<html>
<body>
<!-- Static nav icons — zero JS -->
<Icon name="lucide:home" class="w-5 h-5" />
<!-- Interactive island — lucide-react ships only for this -->
<CommandPalette client:load />
</body>
</html>
// src/components/CommandPalette.tsx (React island)
'use client'; // Not needed in Astro — just a React component
import { Search, Command, ArrowRight } from 'lucide-react';
export function CommandPalette() {
// ...interactive logic here
return (
<div>
<Search size={16} />
{/* ... */}
</div>
);
}
Hydration directives
Astro’s hydration directives control when the icon-containing island becomes interactive:
| Directive | When JS loads | Use for |
|---|---|---|
client:load | Immediately | Above-the-fold interactive icons |
client:idle | Browser is idle | Below-fold interactive elements |
client:visible | When scrolled into view | Lazy icon grids |
client:only | Always JS, no SSR | Icons that depend on browser APIs |
<!-- Icon-heavy sidebar — loads when visible -->
<AnimatedSidebar client:visible />
<!-- Static icon in layout — no hydration -->
<Icon name="lucide:menu" class="w-5 h-5" />
Dark mode in Astro with Tailwind
astro-icon and inline SVGs both use currentColor, so Tailwind’s dark: variant applies without any extra setup:
<div class="text-gray-700 dark:text-gray-300">
<Icon name="lucide:settings" size={20} />
</div>
For script-driven dark mode toggle (localStorage persisted), put the toggle script in <head> to prevent flash:
<head>
<script is:inline>
const theme = localStorage.getItem('theme') ?? 'light';
document.documentElement.classList.toggle('dark', theme === 'dark');
</script>
</head>
Accessibility
<!-- Decorative -->
<Icon name="lucide:arrow-right" class="w-4 h-4" aria-hidden="true" />
<!-- Meaningful standalone -->
<Icon
name="lucide:alert-triangle"
class="w-5 h-5 text-yellow-500"
role="img"
aria-label="Warning"
/>
<!-- In an icon-only link -->
<a href="/settings" aria-label="Settings">
<Icon name="lucide:settings" class="w-5 h-5" aria-hidden="true" />
</a>
Performance checklist for Astro icons
- Static layout icons use
astro-iconor inline SVG (zero JS) - Only icons inside interactive islands use React/Vue icon libraries
-
@iconify-json/<pack>packages are installed individually, not@iconify/json -
client:visibleis used for icon-heavy components below the fold - Dark mode is handled via Tailwind
text-*classes, not JS color injection
Frequently asked questions
Can I use Lucide in .astro files without a React island?
Not directly — Lucide React exports JSX components. Use astro-icon with @iconify-json/lucide instead; it uses the exact same Lucide icon data, zero JS, zero React overhead.
Does astro-icon work in SSR mode (Node adapter)? Yes. Icons are resolved at request time and inlined in the HTML response. There’s no client-side fetch.
Can I build a custom icon with astro-icon?
Yes — place custom SVG files in src/icons/ and reference them as <Icon name="local:my-icon" />.