Adding a Favicon in Astro and SvelteKit
The static directory, the layout file and the head tags for both frameworks — plus the base-path and adapter gotchas that break favicons on deploy.
Quick answer: in Astro, favicon files go in public/ and the tags go in the <head> of your shared layout component. In SvelteKit, files go in static/ and the tags go in src/app.html. Both directories are copied to the site root verbatim at build time, so reference everything with absolute paths (/favicon.ico), not relative ones.
These two frameworks handle favicons almost identically once you know which directory is the untouched-copy one. The differences that matter are in where the <head> lives and how each one deals with a deployment base path.
Astro
1. The files
Astro copies everything in public/ to the root of the build output without renaming it. That's exactly the behaviour a favicon needs, since browsers request /favicon.ico directly whether or not your HTML mentions it.
public/
├── favicon.ico
├── favicon-16x16.png
├── favicon-32x32.png
├── apple-touch-icon.png
├── icon-192.png
├── icon-512.png
└── site.webmanifest
Do not put them in src/assets/. Files there go through Astro's asset pipeline, get content-hashed filenames, and are only emitted if something imports them — neither of which works for an icon that has to live at a fixed URL.
2. The tags
Astro has no single index.html. The <head> lives in whichever layout component your pages wrap themselves in — conventionally src/layouts/Layout.astro:
---
const { title } = Astro.props;
---
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" sizes="32x32" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" />
<meta name="theme-color" content="#ffffff" />
<title>{title}</title>
</head>
<body>
<slot />
</body>
</html>
Put this in the layout, not in individual pages. Icons should be identical on every route, and duplicating them per page is how sites end up with one stale declaration nobody notices.
3. Delete the starter icon
Astro's starter templates ship with public/favicon.svg and this line:
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
If you add your own icons and leave that line in, the browser has two valid declarations to choose from and will often prefer the SVG — so you keep seeing the Astro logo and conclude your files didn't deploy. Remove both the tag and public/favicon.svg.
If you want an SVG icon (they're genuinely nice — one file, crisp at every size, and they can carry a dark-mode media query), keep the tag but point it at your own file, and still ship the .ico and PNGs alongside it for the browsers that don't read SVG icons.
4. Base paths
If you deploy to a subpath — a GitHub Pages project site, say — set base in astro.config.mjs:
export default defineConfig({
site: 'https://you.github.io',
base: '/my-project',
});
Astro does not rewrite hard-coded absolute paths inside your markup. Build them from import.meta.env.BASE_URL instead:
---
const base = import.meta.env.BASE_URL.replace(/\/$/, '');
---
<link rel="icon" href={`${base}/favicon.ico`} sizes="32x32" />
<link rel="apple-touch-icon" href={`${base}/apple-touch-icon.png`} />
Be aware that this fixes the tags but not the bare /favicon.ico request browsers make on their own — on a subpath deployment, that request goes to the domain root and there's nothing you can do about it from inside the app. It's one of the reasons a root-domain deployment is simpler if you have the choice.
SvelteKit
1. The files
SvelteKit's equivalent of public/ is static/. Same contract: copied to the root of the build untouched.
static/
├── favicon.ico
├── favicon-16x16.png
├── favicon-32x32.png
├── apple-touch-icon.png
├── icon-192.png
├── icon-512.png
└── site.webmanifest
A fresh SvelteKit project has static/favicon.png in it already. Replace or delete it, and update the tag that references it — same duplicate-declaration trap as Astro.
2. The tags
SvelteKit does have an HTML shell: src/app.html. Every page is rendered into it, so this is the right place for icons.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="%sveltekit.assets%/favicon.ico" sizes="32x32" />
<link rel="icon" type="image/png" sizes="32x32" href="%sveltekit.assets%/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="%sveltekit.assets%/favicon-16x16.png" />
<link rel="apple-touch-icon" href="%sveltekit.assets%/apple-touch-icon.png" />
<link rel="manifest" href="%sveltekit.assets%/site.webmanifest" />
<meta name="theme-color" content="#ffffff" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
The %sveltekit.assets% placeholder is the important part, and it's the one thing SvelteKit does better than the others here: it resolves to the correct path automatically, including when you've configured paths.base or paths.assets. Use it rather than a hard-coded leading slash, and base-path deployments stop being a special case.
Don't use <svelte:head> in a layout component for favicons. It works, but it means the icon tags are injected as part of page rendering rather than being present in the shell — an unnecessary difference for something that never changes between routes.
3. Adapter notes
- adapter-static: everything in
static/ends up in the output directory as-is. Nothing extra to do. - adapter-node / serverless adapters: static files are still served from the root, but if you put a CDN or reverse proxy in front, make sure
/favicon.icoisn't being caught by an SPA fallback rule that returnsindex.htmlfor unknown paths. An.icoserved astext/htmlrenders as nothing, with no error in the console — a genuinely annoying failure mode to track down.
Verifying either one
The same four checks apply to both frameworks, in this order:
- Request
/favicon.icodirectly in a new tab. It should render an image, not your app shell and not a 404. - Read the served HTML with
curlorview-source:, not the DevTools Elements panel — the panel shows the live DOM, which can differ from what was sent. - Confirm the content type is
image/x-iconorimage/vnd.microsoft.iconfor the.icoandimage/pngfor the PNGs. - Hard-reload or use a private window. Favicon caching is unusually sticky and a normal refresh often shows you the old icon.
The Checker runs the equivalent of all four against a live URL and reports which icons your deployed site declares, which ones actually load, and what's missing for iOS and Android.
Summary table
| Astro | SvelteKit | |
|---|---|---|
| Static directory | public/ |
static/ |
Where the <head> lives |
src/layouts/*.astro |
src/app.html |
| Base-path helper | import.meta.env.BASE_URL |
%sveltekit.assets% |
| Starter icon to delete | public/favicon.svg |
static/favicon.png |
Generate the full set — multi-resolution .ico, every PNG size, the Apple touch icon and the manifest — from an image, icon, emoji or a couple of letters with the Generator. It outputs the <head> snippet in the same shape as the examples above.
✦ Try the Generator
Drop in artwork, type a word, or pick an emoji. Watch it land in a real browser tab, then export every size a modern site needs.
Open GeneratorWritten by Abdessamad Bettal
Web developer, and the person who builds and writes favicon.tools. The ICO packer, manifest writer and site checker behind favicon.tools were all written from the file-format specs and tested against real browsers — which is where the detail in these guides comes from. Spotted something wrong? Write to contact@favicon.tools.