Platform Guides

Favicons in Astro, Nuxt and SvelteKit

Same files, same manifest, three different places to put them. The current head APIs for each framework, what dark mode really supports, and the one mistake all three share.

11 min read
Free Guide

What all three have in common

The files don't change. Astro, Nuxt and SvelteKit all ship plain HTML to a browser, and browsers have not changed their minds about favicons in years. You need the same set everywhere:

favicon.ico            16, 32 and 48 packed into one file
favicon.svg            optional, but the best-looking option where it works
favicon-32x32.png      the workhorse
favicon-48x48.png      what Google uses in search results
apple-touch-icon.png   180x180, iOS home screen
android-chrome-192x192.png
android-chrome-512x512.png
manifest.webmanifest

The manifest doesn't change either. It's a W3C spec, not a framework feature, and the same file works in all three.

What changes is two things: which directory the build copies verbatim, and how you get link tags into the document head. That's genuinely the whole difference, and it's why one post beats three.

One more thing they share, which surprises people: none of these frameworks fingerprint the files in their static directory. Astro copies public/ "untouched". Nuxt says files in public/ "are served at the root and are not modified by the build process". SvelteKit's static/ is for assets "that should be served without any alteration to the name". Your bundled JavaScript gets a content hash and a long cache life. Your favicon does not. Hold that thought for the cache-busting section.

Astro

Astro 7 is current at the time of writing, and nothing here changed between 5, 6 and 7 apart from the view transitions detail below.

Files go in public/. From the docs: "The files in this folder will be copied into the build folder untouched, and then your site will be built." Nothing processes them, nothing optimises them, nothing renames them. public/favicon.svg is served at /favicon.svg.

Head content is just HTML. There's no head API to learn. You write link tags in your layout's <head>, and the standard pattern is to factor them into a component so you write them once:

---
// src/components/Favicons.astro
---
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" href="/favicon.ico" sizes="any" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="manifest" href="/manifest.webmanifest" />
<meta name="theme-color" content="#2e5c85" />
---
// src/layouts/Layout.astro
import Favicons from '../components/Favicons.astro';
const { title } = Astro.props;
---
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width" />
    <title>{title}</title>
    <Favicons />
  </head>
  <body>
    <slot />
  </body>
</html>

View transitions. The component is <ClientRouter />, imported from astro:transitions. It was called <ViewTransitions /> until Astro 5 renamed it, and Astro 6 removed the old name entirely, so any tutorial still importing ViewTransitions will fail outright on a current project.

---
import { ClientRouter } from 'astro:transitions';
---
<head>
  <ClientRouter />
</head>

The good news, and I want to be clear about this because there's a lot of invented advice on the subject: you don't need to do anything to protect your favicon across a transition. Astro swaps the head on navigation, keeping stylesheets and scripts that exist on the new page, and appending the new page's head elements. Your favicon links live in the shared layout, so they're in every new page's head and get re-emitted identically. There's no transition:persist needed, and Astro's docs don't ask for one. Adding it is cargo cult.

Nuxt

Nuxt 4 is current. There are two valid approaches and they compose fine.

Files go in public/, served at the root, unmodified. Nuxt's docs name favicon.ico specifically as the kind of file that belongs there.

Worth understanding the mechanism: a bare public/favicon.ico works with no configuration at all, but only because browsers request /favicon.ico implicitly. Nothing declares it. The moment you want an SVG icon, an Apple touch icon or a manifest, you have to declare them yourself.

Option 1: app.head in nuxt.config.ts. Static, applies everywhere, single source of truth:

// nuxt.config.ts
export default defineNuxtConfig({
  app: {
    head: {
      link: [
        { rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' },
        { rel: 'icon', href: '/favicon.ico', sizes: 'any' },
        { rel: 'icon', type: 'image/png', sizes: '32x32', href: '/favicon-32x32.png' },
        { rel: 'apple-touch-icon', sizes: '180x180', href: '/apple-touch-icon.png' },
        { rel: 'manifest', href: '/manifest.webmanifest' },
      ],
      meta: [{ name: 'theme-color', content: '#2e5c85' }],
    },
  },
});

Option 2: useHead() in app.vue. Auto-imported, no import statement needed, and it takes the same shape:

<script setup lang="ts">
useHead({
  link: [
    { rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' },
    { rel: 'apple-touch-icon', sizes: '180x180', href: '/apple-touch-icon.png' },
    { rel: 'manifest', href: '/manifest.webmanifest' },
  ],
});
</script>

Which one? Config for anything that never changes, which is nearly always the right answer for favicons. useHead() earns its place when the value has to be computed.

There is one case where the config route bites you. If you set app.cdnURL, a literal href: '/favicon.ico' in head config is not rewritten to your CDN host. Nuxt's own docs steer you to useHead() in app.vue with the path built from runtime config for that scenario. Not a common setup, but a genuinely confusing hour if you hit it without knowing.

SvelteKit

Files go in static/. Served without any alteration to the name, which is exactly what you want for robots.txt and favicons. The SvelteKit docs actually recommend minimising what you put there, since imported assets get hashed by Vite and these don't. Favicons are the exception that has to live there anyway, because their URLs are fixed by convention.

Head tags go in src/app.html, and the placeholder matters:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width" />
    <link rel="icon" type="image/svg+xml" href="%sveltekit.assets%/favicon.svg" />
    <link rel="icon" href="%sveltekit.assets%/favicon.ico" sizes="any" />
    <link rel="apple-touch-icon" sizes="180x180" href="%sveltekit.assets%/apple-touch-icon.png" />
    <link rel="manifest" href="%sveltekit.assets%/manifest.webmanifest" />
    %sveltekit.head%
  </head>
  <body data-sveltekit-preload-data="hover">
    <div style="display: contents">%sveltekit.body%</div>
  </body>
</html>

%sveltekit.assets% resolves to paths.assets if you've configured one, and otherwise to a relative path to paths.base. That's the whole point of it. Hardcode /favicon.svg and your icons work perfectly in development and 404 the day you deploy under a base path, which is the standard GitHub Pages story.

<svelte:head> is the alternative, for anything that has to be per-route or computed. It inserts elements into document.head and must sit at the top level of a component, never inside a block. One important detail: %sveltekit.assets% is an app.html placeholder and means nothing inside a component. Import the real values instead:

<script>
  import { assets } from '$app/paths';
</script>

<svelte:head>
  <link rel="icon" type="image/svg+xml" href="{assets}/favicon.svg" />
</svelte:head>

For favicons specifically, app.html is the right home. They're the same on every page, and the placeholder handles the base path for you.

Dark mode SVG favicons, honestly

An SVG favicon can carry its own stylesheet, and that stylesheet can respond to the system colour scheme. The file looks like this and works identically in all three frameworks, because it's a file, not a framework feature:

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
  <style>
    .mark { fill: #101418; }
    @media (prefers-color-scheme: dark) {
      .mark { fill: #f5f2ea; }
    }
  </style>
  <circle class="mark" cx="16" cy="16" r="14" />
</svg>

Now the part most guides skip. Support is partial, and it's partial in a way that matters.

Chromium browsers and Firefox honour the media query inside an SVG favicon. Safari does not: it supports SVG favicons, and it supports prefers-color-scheme on pages, but it doesn't apply the SVG's embedded styles when it renders the icon. Safari users see your light variant on a dark tab bar.

There's a second mechanism, the media attribute on the link element itself:

<link rel="icon" href="/favicon-light.svg" media="(prefers-color-scheme: light)" />
<link rel="icon" href="/favicon-dark.svg" media="(prefers-color-scheme: dark)" />

Chromium implements this. Firefox does not - the bug asking for it has been open since 2019 and is still unresolved, and the Mozilla-side recommendation is to put the media query inside the SVG instead. Safari's behaviour here I could not verify against a primary source, so I won't claim it either way.

Which leaves an unsatisfying but true conclusion: the two mechanisms are complementary rather than redundant, neither covers Safari, and there is no combination that covers everyone. So the real fix is a design one. Pick a mark that reads acceptably on both a light and a dark tab strip - usually meaning strong mid-tone contrast rather than pure black or pure white - and treat dark mode variants as an improvement for the browsers that support them, not a requirement. That principle is the same one behind everything in favicon design best practices: a favicon that only works under ideal conditions isn't finished.

Cache-busting on deploy

Here's where the shared "static directories aren't fingerprinted" fact comes due.

Your bundle is fine. Vite hashes it, so a new build produces new URLs and browsers fetch them. Your favicon has the same URL it had last year. HTTP caches are keyed on URL, so from a cache's point of view, nothing has changed.

The mechanism for fixing that is unglamorous and identical everywhere: change the URL.

<!-- Astro layout -->
<link rel="icon" type="image/svg+xml" href="/favicon.svg?v=3" />
// nuxt.config.ts
link: [{ rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg?v=3' }];
<!-- src/app.html -->
<link rel="icon" type="image/svg+xml" href="%sveltekit.assets%/favicon.svg?v=3" />

A query string makes it a different resource as far as any cache is concerned, so the old entry can't answer for it. Bump the number when the icon changes, and only when the icon changes.

I'd rather not repeat the folklore about browsers keeping favicons for weeks in a special store that survives a hard refresh. I couldn't find an authoritative source for any of it, and neither could anyone I've asked. What is certain is the cache-key behaviour above, and it's enough to solve the problem regardless of what the specific numbers are.

The one mistake all three share

Declaring an icon the build never copies.

It looks identical in every framework: the link tag is right, the filename is right, the path is right, and production serves a 404 while local development looks perfect. The cause is always that the file is somewhere the build treats as source rather than as a static asset.

  • Astro: the icon is in src/ or src/assets/ instead of public/. Files in src/assets get processed and renamed with a content hash, so /favicon.svg doesn't exist in the output.
  • Nuxt: same shape, assets/ instead of public/. assets/ is for things the bundler should handle, and the bundler renames them.
  • SvelteKit: either the file is in src/lib instead of static/, or it's in static/ but you hardcoded /favicon.svg and deployed under a paths.base.

Angular developers will find this familiar, since the same class of bug there is an angular.json assets array missing an entry - we covered it in the Angular favicon guide. It's the same mistake wearing different clothes.

The check takes ten seconds and I'd do it on every deploy that touches icons. Build, then look:

npm run build
ls dist/favicon.svg dist/apple-touch-icon.png dist/manifest.webmanifest

(dist/ for Astro, .output/public/ for Nuxt, build/ for a typical SvelteKit adapter.) If the file isn't in the output, no link tag will save it.

Generate the set, then wire it

None of this works without the files, and hand-cutting eight PNGs in a design tool is nobody's idea of a good afternoon.

Our favicon generator runs entirely in your browser. Drop in a logo and you get favicon.ico with 16, 32 and 48 inside it, the PNG sizes, apple-touch-icon.png at 180, the 192 and 512 Android icons, a maskable 512 for adaptive Android icons, and a manifest.webmanifest. You can set a background colour, padding and shape before it processes. Nothing uploads anywhere, which matters more than it sounds when the logo is a client's unreleased brand.

Two limits, stated plainly. It outputs an SVG favicon only when you give it an SVG source, because it can't vectorise a PNG - and the SVG it produces wraps your image rather than being hand-authored vector paths, so it isn't the file to add a dark-mode media query to. For that, author the SVG yourself using the pattern above. It also has no light/dark toggle: run an inverted logo through a second time if you want both variants.

If you're on a different framework, the same file set applies. We've covered Next.js 15's App Router conventions, React with Vite, and Angular separately, since each has enough of its own machinery to be worth its own post.

Quick reference

AstroNuxtSvelteKit
Static directorypublic/public/static/
Head tags in.astro layoutapp.head or useHead()src/app.html
Per-route headcomponent in <head>useHead()<svelte:head>
Base-path helpernone needednone needed%sveltekit.assets%
Static files hashedNoNoNo
Build outputdist/.output/public/build/ (adapter dependent)

Same files, three doors. Pick the right door, check the build output, and move on to something more interesting.