SVG in Next.js 16: next/image, Inline JSX, SVGR and Icons
There are half a dozen sensible ways to use SVG in Next.js, and one question picks between them: does anything need to reach inside the drawing? If not — a logo, an illustration — ship the SVG as a file and let the browser cache it. If CSS has to recolour it, a screen reader has to read it or a script has to animate a path, it has to become markup in your React tree. Every sample below targets Next.js 16.3 with the App Router and Turbopack, was built with next build on that version, and follows the docs that ship inside the package rather than the webpack-era advice that still ranks.
Which way to use SVG in Next.js
Pick a row, then jump to its section. Styleable means CSS on the page can change colours inside the drawing. Cached means the browser keeps the SVG as its own file between pages instead of receiving it again inside every HTML document.
| Method | Styleable? | Cached? | Bundle cost | Best for |
|---|---|---|---|---|
| Static import + next/image | No | Yes, hashed URL, immutable for a year | None; one request | Logos, illustrations |
| img from /public | No | Revalidated on every load (max-age=0) | None; one request | Stable URLs, CMS paths |
| Inline JSX component | Yes: currentColor, classes, props | No, part of the page | Markup in HTML and RSC payload; JS only in client components | A few icons you own |
| SVGR import | Yes | No | As inline JSX, plus a loader | Icon sets kept as .svg files |
Sprite with <use> | Colour via currentColor | Yes, one file for all icons | One request, a few bytes per use | Many repeated icons |
| app/icon.svg | Its own media queries only | Hashed link, max-age=0 | None | The favicon |
next/image and static imports: what happens to an SVG
To import an SVG in Next.js as a file, import it the way you would a PNG. You get an object, not markup: a hashed URL plus the width and height Next read from the file. Hand it to next/image and the dimensions arrive for free, which stops layout shift.
// app/page.jsx (Next.js 16.3, App Router, Turbopack)
import Image from 'next/image'
import logo from './logo.svg'
// logo is an object, not markup:
// { src: '/_next/static/media/logo.<hash>.svg', width: 120, height: 40, ... }
export default function Page() {
return <Image src={logo} alt="Acme" />
}
// What next build rendered (trimmed):
// <img alt="Acme" loading="lazy" width="120" height="40" decoding="async"
// src="/_next/static/media/logo.2rn_08u_hgwr8.svg">Look at the src. It is not /_next/image. Because the path ends in .svg, next/image marks it unoptimized and renders a plain img — the Image component docs say this happens automatically for any src ending in .svg. So there is no resizing, no format conversion and no srcset. What you do get is the width and height, lazy loading, and a content-hashed file under /_next/static/media, which Next serves with a one-year immutable Cache-Control.
That settles a claim repeated across older tutorials and forum answers: you do not need dangerouslyAllowSVG to show a local SVG with next/image. That flag does something else, covered two sections down.
A plain img from the public folder
Anything in public/ is served from the site root, so public/logo.svg is /logo.svg with no import. With a string src, next/image requires width and height and, by the same rule as above, renders a plain img. The two options below produce almost identical HTML; next/image only adds loading="lazy" and decoding="async".
// public/logo.svg is served at /logo.svg, no import needed
import Image from 'next/image'
// Option A: next/image. A string src needs width and height.
export function Logo() {
return <Image src="/logo.svg" alt="Acme" width={120} height={40} />
}
// Option B: a plain img. Next's ESLint config flags it (no-img-element),
// but for an SVG there is nothing for next/image to optimise.
export function LogoPlain() {
// eslint-disable-next-line @next/next/no-img-element
return <img src="/logo.svg" alt="Acme" width={120} height={40} />
}The real difference from a static import is caching. The public folder docs say Next serves these files with Cache-Control: public, max-age=0, because it cannot know whether they changed, and a test server did exactly that. In exchange you get a stable, readable URL, which suits paths stored in a CMS or referenced from outside the app.
Remote SVG, next/image and dangerouslyAllowSVG
An SVG is a document that can carry scripts and event handlers. In an img tag they never run — browsers disable scripts for SVG used as an image — but if the file is served from your domain and someone opens its URL directly, it runs as a page on your origin. The image optimiser at /_next/image fetches remote files and re-serves them from your origin, so proxying SVG would lend third-party markup your domain. That is why it refuses SVG by default.
In practice, on 16.3, a remote URL ending in .svg is treated like a local one: marked unoptimized and rendered as a plain img pointing at the other host. No proxy, no remotePatterns check — a test build rendered one with none configured. The refusal bites when a URL serves SVG without the extension, as avatar and badge endpoints often do. The request goes to the optimiser, which sniffs the content type, answers 400 and logs a hint to add unoptimized. Do that.
import Image from 'next/image'
export function RemoteImages() {
return (
<>
{/* Ends in .svg: rendered as a plain img pointing at the remote host.
No proxy through your origin, no remotePatterns entry needed. */}
<Image src="https://assets.example.com/badge.svg" alt="Build passing" width={90} height={20} />
{/* Serves SVG without the extension: without unoptimized this goes
to /_next/image, which refuses SVG with a 400. */}
<Image src="https://avatars.example.com/u/42" alt="Avatar" width={64} height={64} unoptimized />
</>
)
}Setting dangerouslyAllowSVG: true flips the default: every SVG, local ones included, goes through /_next/image. SVG is on the optimiser's pass-through list, so the bytes come back unchanged. In our test they were byte-identical, now behind a 1x and a 2x srcset entry for the same file. You gain a proxy hop and some headers, not a smaller image. If you need it anyway, set the two headers the docs recommend:
// next.config.mjs: only if SVG really must go through /_next/image
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
dangerouslyAllowSVG: true,
contentDispositionType: 'attachment',
contentSecurityPolicy: "default-src 'self'; script-src 'none'; sandbox;",
remotePatterns: [new URL('https://assets.example.com/**')],
},
}
export default nextConfigInline SVG as a JSX component
When CSS or a screen reader needs the inside of the drawing, put the markup in your React tree. A component that returns <svg> needs no hooks, so it works as a Server Component and ships no JavaScript. It still costs bytes: on a first load the App Router embeds the RSC payload in the same HTML document, so the path data travels twice — our prerendered test page contained the same path string twice. Fine for a 400-byte icon, wasteful for a 40 KB illustration, which belongs in an img. If a 'use client' file imports the icon, its code joins the client bundle as well.
Pasted markup needs converting, because JSX is not XML, and the mistakes fail in different ways:
- Hyphenated attributes such as
stroke-width,fill-rule,clip-ruleandstop-colorbecomestrokeWidth,fillRule,clipRuleandstopColor. Left alone they still render, but React logs an "Invalid DOM property" warning for each in development. - class becomes
className. - xlink:href fails the build outright: the compiler reports that JSX namespaces are disabled. Use plain
href, which SVG 2 prefers anyway. - A style string such as
style="fill:red"fails at render. React wants an object:style={{ fill: 'red' }}. - Duplicate ids: two copies of an icon with
id="grad"on one page share one id, and the second gradient quietly points at the first. Generate ids with React'suseId, which also works in Server Components.
The React tab in our SVG editor handles the mechanical part. Paste your SVG into the code panel (pasting markup is the only way in; there is no file import), switch to React, and copy. It renames stroke-width, stroke-linecap, stroke-linejoin, stroke-dasharray, font-size, font-family, font-weight, text-anchor and class, strips comments and wraps the result in a component. That is all it does. Here is its output for a two-path icon, re-indented:
import React from 'react';
export default function GeneratedVector(props) {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path fill="#0f172a" fill-rule="evenodd" clip-rule="evenodd" d="M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20Zm0 3a7 7 0 1 1 0 14 7 7 0 0 1 0-14Z"/>
<path d="m8.5 12 2.5 2.5 4.5-5" fill="none" stroke="#0f172a" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
);
}Note what is left: fill-rule and clip-rule still hyphenated, colours hard-coded, props accepted but never used. The converter also leaves stop-color and xlink:href alone, adds no currentColor, keeps style strings as strings and emits no TypeScript, so expect to finish those by hand — which is the next section.
Colour and size: currentColor and props
currentColor is a CSS keyword meaning the element's own color value. Put it in fill or stroke and the icon takes the text colour of whatever it sits in, so a class on the parent recolours it with no prop at all. Set width and height to 1em and it sizes with the font. Spread props last so callers can pass className, style, aria-label or onClick and override the defaults. The converter output, finished:
// components/icons/CheckCircle.jsx (no 'use client' needed)
export default function CheckCircle({ title, ...props }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="1em"
height="1em"
role={title ? 'img' : undefined}
aria-hidden={title ? undefined : true}
{...props}
>
{title && <title>{title}</title>}
<path
fill="currentColor"
fillRule="evenodd"
clipRule="evenodd"
d="M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20Zm0 3a7 7 0 1 1 0 14 7 7 0 0 1 0-14Z"
/>
<path
d="m8.5 12 2.5 2.5 4.5-5"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}// app/orders/page.jsx: a Server Component, so the icon adds no client JS
import CheckCircle from '@/components/icons/CheckCircle'
export default function Orders() {
return (
<>
{/* Decorative: takes the paragraph's colour and font size */}
<p style={{ color: '#047857' }}>
<CheckCircle /> Saved
</p>
{/* Meaningful on its own: give it a title */}
<CheckCircle title="Payment received" style={{ color: 'green', fontSize: 32 }} />
</>
)
}This is also the honest answer to how to change the colour of an SVG shown with next/image: you cannot. An SVG loaded as an image is sealed, and page CSS does not reach inside it, currentColor included. Either inline it as above or, for a single-colour icon, use the file as a CSS mask over a background colour, as the SVG background image post shows.
SVGR with Turbopack in Next.js 16
SVGR converts at build time: import an .svg file, get a component. The catch is the bundler. Next.js 16 builds with Turbopack by default, and the version 16 upgrade guide says next build now fails when the config contains a custom webpack function. The webpack(config) SVGR snippet in most ranking tutorials either stops the build or forces the --webpack flag. The Turbopack equivalent is turbopack.rules, and @svgr/webpack is on its list of tested loaders.
The docs' minimal rule sends every .svg import through SVGR, with a side effect we confirmed in a build: plain imports stop returning { src, width, height }, so static imports for next/image break (app/icon.svg is unaffected; it does not pass through your rules). Since 16.2 a rule can match the import's query string, which makes SVGR opt-in:
// next.config.mjs (Next.js 16.2+ for condition.query, @svgr/webpack 8.1)
/** @type {import('next').NextConfig} */
const nextConfig = {
turbopack: {
rules: {
'*.svg': {
// Only imports ending in ?svgr become components.
// Plain './logo.svg' imports stay static images for next/image.
condition: { query: /[?&]svgr(?=&|$)/ },
loaders: [{ loader: '@svgr/webpack', options: { icon: true } }],
as: '*.js',
},
},
},
}
export default nextConfig// app/page.jsx
import Image from 'next/image'
import logo from './logo.svg' // { src, width, height }
import CheckIcon from './check.svg?svgr' // a React component
export default function Page() {
return (
<>
<Image src={logo} alt="Acme" />
<CheckIcon className="icon" aria-hidden="true" />
</>
)
}Why icon: true, and what SVGR will not do
@svgr/webpack 8.1, the current release, runs SVGO 3, whose default preset removes the viewBox when it matches width and height, and removes title elements. SVGR switches the viewBox removal off only when icon or dimensions: false is set. Without either, an icon resized with CSS stops scaling. icon: true keeps the viewBox and sets width and height to 1em; for titles, add titleProp: true from the SVGR options and pass a title where you use the icon.
SVGR does not add currentColor on its own. Save the source files with currentColor, or map a colour with replaceAttrValues, for example { '#0f172a': 'currentColor' }; Turbopack needs loader options to be plain values, and that object is. Its default output is a plain function component with no hooks, so it renders in Server Components with the same twice-in-the-HTML caveat as hand-written inline SVG.
For TypeScript, Next's own declarations type every *.svg import as any, precisely so SVGR setups do not clash. Declare the query form and you get a typed component without touching that:
// svgr.d.ts in the project root (Next's default tsconfig include picks it up)
declare module '*.svg?svgr' {
import type { FC, SVGProps } from 'react'
const Component: FC<SVGProps<SVGSVGElement>>
export default Component
}Many icons: one sprite and use
Inline icons repeat their markup at every use. A sprite puts each icon in a <symbol> inside one file, and each use costs only a <use> element pointing at it. Colour still flows in: currentColor inside the symbol resolves against the use element's colour.
<!-- components/icons.svg: the root needs a viewBox (or width and height),
or the static import below fails the build -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<symbol id="check" viewBox="0 0 24 24">
<path d="M20 6 9 17l-5-5" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</symbol>
<symbol id="close" viewBox="0 0 24 24">
<path d="M18 6 6 18M6 6l12 12" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round"/>
</symbol>
</svg>// components/Icon.jsx
import sprite from './icons.svg' // hashed URL under /_next/static/media
export function Icon({ name, ...props }) {
return (
<svg width="1em" height="1em" aria-hidden="true" {...props}>
<use href={`${sprite.src}#${name}`} />
</svg>
)
}
// <button type="button"><Icon name="close" /> Close</button>
// renders <use href="/_next/static/media/icons.<hash>.svg#close">Importing the sprite, rather than dropping it in public/, is the Next-specific trick: the import returns a hashed URL that gets immutable caching, so one download serves every page until the sprite changes. The limits: the browser downloads the whole sprite even if a page uses two icons, and browsers apply the same-origin policy to <use>. With an assetPrefix on a CDN domain, the imported URL becomes cross-origin (we checked) and the icons vanish; in that setup keep the sprite in public/, which assetPrefix does not touch. Under the global SVGR rule this import turns into a component, which the ?svgr opt-in avoids. The trade-offs against icon fonts are in SVG icons vs icon fonts.
The app/icon.svg favicon convention
For the favicon, skip next/image entirely. Put icon.svg in app/ and Next writes the link tag for you, with sizes="any" and a content hash in the URL. Per the app icons docs, icon accepts .ico, .jpg, .jpeg, .png and .svg, favicon accepts only .ico, and apple-icon only .jpg, .jpeg or .png. The dependable set is app/favicon.ico, app/icon.svg and app/apple-icon.png. Keep the .ico: caniuse lists SVG favicon support in Safari only from version 26. This site serves its own tab icon from app/icon.svg.
<!-- app/icon.svg: Next.js writes the <link> tag for you -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<style>
.mark { fill: #0f172a; }
@media (prefers-color-scheme: dark) {
.mark { fill: #f8fafc; }
}
</style>
<rect class="mark" x="4" y="4" width="24" height="24" rx="6"/>
</svg>
<!-- What next build put in <head>:
<link rel="icon" href="/icon.svg?icon.3bxjsy7878zj7.svg" sizes="any" type="image/svg+xml"> -->The favicon is a standalone document, so a prefers-color-scheme query inside it can switch colours for dark browser chrome; the dark-mode favicon post covers where that works. Our favicon generator exports PNGs only — 16, 32 and 48 px, a 180 px apple-touch-icon, and Android 192 and 512 — which covers apple-icon.png. It does not produce the SVG or the .ico; those come from your source file.
When an SVG will not show in Next.js
- Failed to parse svg source code for image dimensions: a static import of a file with no viewBox and no width or height. Add a viewBox.
- JSX Namespace is disabled by default: an
xlink:hrefor other prefixed attribute in pasted markup. Rename it tohref. - A 400 from /_next/image for a remote SVG: the URL lacks .svg, so it was sent to the optimiser. Add
unoptimized. - Hostname is not configured under images: the src went to the optimiser (a non-.svg URL, or dangerouslyAllowSVG is on). Add a remotePatterns entry, or use
unoptimized. - The webpack SVGR config is ignored or the build refuses to run: Next.js 16 uses Turbopack. Move the rule to turbopack.rules, or build with
--webpack.
For causes that are not Next-specific — a missing xmlns, a wrong MIME type, a zero-size viewport — see why an SVG is not showing.
Where to go next
To turn a file into a component, paste it into the SVG editor, copy from the React tab and finish it as shown above. The SVG to React page lists the attribute renames in full and has a TypeScript version. If you need icons to put in a sprite, the AI icon generator produces SVG in the logged-in dashboard, and every generated file is saved with a viewBox — the attribute a static import needs to read its size and an inline icon needs to scale.
Further reading: combining SVG and CSS for fills set from stylesheets, SVG performance for the inline-versus-img cost, and accessible vector graphics for labelling inlined icons.
Frequently asked questions
- How do I use SVG in Next.js?
- Choose by whether anything must reach inside the drawing. For a logo or illustration, import the .svg and pass it to next/image, or reference a file in public/ with an img tag; both render a plain img and the browser caches the file. When CSS must recolour it or a screen reader must read it, write it as a JSX component or convert files at build time with SVGR. Use app/icon.svg for the favicon.
- Does next/image support SVG?
- Yes, but it does not optimise it. In Next.js 16 any src ending in .svg is automatically treated as unoptimized, so next/image renders a plain img pointing at the original file: no resizing, no format conversion, no srcset. You still get width and height from a static import, lazy loading and, for imported files, a hashed URL with long-lived immutable caching.
- Do I need dangerouslyAllowSVG to show an SVG with next/image?
- No. A local or remote src ending in .svg renders without it. dangerouslyAllowSVG makes every SVG go through the /_next/image optimiser instead, which hands back the same bytes, so it rarely helps. If you do enable it, also set contentDispositionType to attachment and a contentSecurityPolicy such as default-src 'self'; script-src 'none'; sandbox; as the Next.js docs recommend.
- Why does next/image refuse remote SVG?
- An SVG can contain scripts. The optimiser re-serves remote files from your own domain, and an SVG opened directly from your origin could run its scripts there, so the optimiser rejects SVG with a 400 by default. This only happens when the URL does not end in .svg, as with many avatar endpoints. Add the unoptimized prop and the browser loads the file directly as an image, where scripts never run.
- How do I import an SVG as a React component in Next.js 16 with Turbopack?
- Install @svgr/webpack and add a turbopack.rules entry for *.svg with loaders set to @svgr/webpack and as set to *.js. The old webpack() function approach no longer works by default, because next build now uses Turbopack and fails when a custom webpack config is present. On 16.2 or later, add a query condition so only imports ending in ?svgr become components and plain imports stay static images.
- How do I change the colour of an SVG in Next.js?
- Only an inline SVG can be recoloured by page CSS. Set fill or stroke to currentColor in the markup, render it as a JSX or SVGR component, and the icon takes the text colour of its parent. An SVG shown through next/image or an img tag is sealed, so CSS cannot reach inside it. For a single-colour icon you can instead use the file as a CSS mask over a background colour.
- How do I type SVG imports in TypeScript in Next.js?
- Next.js types every *.svg import as any, so nothing breaks, but nothing is checked either. With SVGR behind a ?svgr query, add a declaration file such as svgr.d.ts that declares the module '*.svg?svgr' with a default export typed as FC<SVGProps<SVGSVGElement>> from react. It does not clash with Next's own declaration, and Next's default tsconfig include pattern picks the file up.
- How do I add an SVG favicon in the Next.js App Router?
- Save the file as app/icon.svg. Next.js adds the link rel="icon" tag with type image/svg+xml, sizes any and a hashed URL for you. The favicon convention only accepts .ico and apple-icon only .jpg, .jpeg or .png, so the usual set is app/favicon.ico, app/icon.svg and app/apple-icon.png. Keep the .ico, because caniuse lists SVG favicon support in Safari only from version 26.
