Favicon Setup for Astro — Free, Fast & Private
Astro draws a hard line between src/assets and public/. Anything imported from src/ goes through the asset pipeline and comes out optimised, hashed and renamed — exactly what you do not want for a favicon, whose filename is load-bearing. public/ is the untouched-copy folder, and it is where all eight files belong.
Where each generated file goes in Astro
The generator above emits eight files with fixed names, site.webmanifest included. Static assets in Astro live in public/, and the markup is written in src/layouts/Layout.astro (or a shared BaseHead.astro). This is the mapping between the two.
| Generated file | Goes to | Notes |
|---|---|---|
| favicon.ico | public/favicon.ico | The starter ships favicon.svg — this replaces or joins it. |
| favicon-16x16.png | public/favicon-16x16.png | Linked from the layout head. |
| favicon-32x32.png | public/favicon-32x32.png | Linked from the layout head. |
| favicon-48x48.png | public/favicon-48x48.png | Optional — already inside the .ico. |
| apple-touch-icon.png | public/apple-touch-icon.png | iOS home screen, 180×180. |
| android-chrome-192x192.png | public/android-chrome-192x192.png | Manifest icon. |
| android-chrome-512x512.png | public/android-chrome-512x512.png | Manifest icon and splash. |
| site.webmanifest | public/site.webmanifest | Copied to the top of dist/ beside the icons, which is what its relative paths resolve against. |
One BaseHead component, not one layout
Astro is a multi-page framework. Every route produces its own HTML document, and each document gets its head from whichever layout wrapped it. A brand-new project has a single Layout.astro and it is tempting to treat that as the head, but Astro sites accumulate layouts fast — a blog layout, a docs layout, a bare landing-page layout with no chrome — and the moment a second one appears, half your pages can quietly lose their icons.
The pattern that survives contact with a growing site is a BaseHead.astro component holding the tags every page needs: charset, viewport, favicons, and the props-driven title and description. Every layout imports it. Adding a layout then costs one import line, and there is exactly one place to edit when the icon set changes.
Astro does hoist and deduplicate the assets it injects itself, but it does not go hunting through your components for head tags to relocate. If a link element is rendered inside the body, that is where it stays. Keep them in the component that is actually placed inside <head>.
src/components/BaseHead.astro
---
const { title, description } = Astro.props;
const base = import.meta.env.BASE_URL;
---
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href={base + 'favicon.ico'} sizes="16x16 32x32 48x48" />
<link rel="icon" type="image/png" sizes="32x32" href={base + 'favicon-32x32.png'} />
<link rel="icon" type="image/png" sizes="16x16" href={base + 'favicon-16x16.png'} />
<link rel="apple-touch-icon" sizes="180x180" href={base + 'apple-touch-icon.png'} />
<link rel="manifest" href={base + 'site.webmanifest'} />
<title>{title}</title>
<meta name="description" content={description} />The starter ships an SVG, and that is worth keeping
A fresh Astro project comes with public/favicon.svg and a single link tag with type="image/svg+xml". SVG favicons are supported across current desktop and mobile browsers and have two real advantages: one file at any resolution, and the ability to respond to the user colour scheme through a media query inside the SVG itself, so a dark mark can flip to a light one against a dark tab strip.
What they do not do is cover everything. Safari on iOS wants the raster apple-touch-icon for home screens, Android install prompts read the manifest PNGs, and enough older software still expects an .ico that the file remains worth shipping. The workable arrangement is to keep the SVG as the preferred icon, then declare the .ico and the PNGs behind it — browsers pick the first format they understand, so ordering the SVG first gives modern browsers the good one and everything else a working fallback.
Keeping the SVG in front of the raster fallbacks
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" href="/favicon.ico" sizes="16x16 32x32 48x48" /> <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
base, site, and subdirectory deployments
If you set the base option in astro.config.mjs — common when publishing a project site to GitHub Pages under a repository path — everything in public/ is served under that prefix. Astro does not rewrite hardcoded absolute paths inside your own markup, so a literal href="/favicon.ico" points at the domain root and misses. Read import.meta.env.BASE_URL and prefix with it, which is exactly why the BaseHead component above does so.
The separate site option is about absolute URLs for the sitemap, canonical tags and RSS. It has nothing to do with where icons resolve, and confusing the two is a reliable way to spend an afternoon. Set both, understand that only base affects asset paths, and test a built site served from a subdirectory before you rely on it.
Static output, SSR, and content collections
In the default static output mode, dist/ contains one HTML file per route plus the copied contents of public/ at the top level, which means the deployed tree looks exactly like a handwritten static site and /favicon.ico is a real file at a real path. With an SSR adapter the split changes — the server bundle lands in one directory and the client assets in another, and it is the client directory that gets served statically. Check where your adapter puts public/ files before assuming they are at the root.
Content collections do not change any of this. A markdown post rendered through a layout inherits that layout head, favicons included. What does change things is a page that renders no layout at all, which is easy to write by accident when prototyping. Such a page has no favicon markup, and the browser falls back to requesting /favicon.ico from the root — which works, but only because the .ico is sitting there.
The manifest — Astro generates nothing here
BaseHead.astro links a manifest on its last line and no Astro template or integration creates that file. Because the default output is a plain static tree, the failure is unusually literal: dist/site.webmanifest simply is not there, the request returns whatever your host serves for a missing file, and the two android-chrome PNGs sit in the build referenced by nothing at all. The generator above downloads it — put it in public/ with the icons, change name and short_name, and it is copied to the top level of dist/ alongside them.
The base caveat from the section above is the part people miss, and the relative src values below are the answer to it. import.meta.env.BASE_URL is an Astro build-time value available in your components; it does not exist inside a static JSON file, so nothing substitutes a prefix there. A hand-written manifest with "/android-chrome-192x192.png" in it therefore resolves — BaseHead prefixed the manifest href correctly — while its icons 404, which is the GitHub Pages failure this cluster keeps warning about, one level deeper. Relative paths are resolved against the manifest's own URL, so they inherit the base prefix the manifest was fetched under. The condition is that both live in public/, which they do.
public/site.webmanifest — as downloaded, edit name and short_name
{
"name": "Your App",
"short_name": "App",
"start_url": ".",
"display": "standalone",
"icons": [
{
"src": "android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}Whatever you paste, the artwork itself never leaves your machine. The set above is rendered with the Canvas API and the .ico container is assembled in JavaScript on your device, so an unreleased logo stays unreleased. If the 16px preview looks like mush, that is the honest signal to simplify the mark before it ships to a Astro tab near you.
