Favicon Setup for React — Free, Fast & Private
Both toolchains use a folder called public/, and both serve its contents from the URL root without hashing or transforming them. The difference is where index.html lives — and in CRA, that the href needs a build-time placeholder rather than a plain slash.
Where each generated file goes in React
The generator above emits seven files with fixed names. Static assets in React live in public/, and the markup is written in index.html (Vite) or public/index.html (CRA). This is the mapping between the two.
| Generated file | Goes to | Notes |
|---|---|---|
| favicon.ico | public/favicon.ico | Overwrite the React logo one the starter shipped. |
| favicon-16x16.png | public/favicon-16x16.png | Linked explicitly from the head. |
| favicon-32x32.png | public/favicon-32x32.png | Linked explicitly from the head. |
| favicon-48x48.png | public/favicon-48x48.png | Optional loose copy — already bundled inside the .ico. |
| apple-touch-icon.png | public/apple-touch-icon.png | iOS home-screen bookmark, 180×180. |
| android-chrome-192x192.png | public/android-chrome-192x192.png | Referenced from manifest.json, not from HTML. |
| android-chrome-512x512.png | public/android-chrome-512x512.png | Referenced from manifest.json, not from HTML. |
Vite: index.html sits above public/, not inside it
The single most confusing thing about a Vite React project for anyone arriving from Create React App is that index.html is not in public/. It is at the project root, a sibling of public/ and src/, because Vite treats it as the actual entry point of the build graph rather than a template to be copied. You edit that file directly and reference public assets with a plain absolute path starting with a slash.
public/ itself behaves the way you would hope: files are copied to the build output root byte-for-byte, with no fingerprinting and no transformation. That is a deliberate escape hatch for assets whose exact filename matters, which is precisely the favicon case — /favicon.ico has to be at that literal path because browsers request it by convention even when no link tag mentions it.
The trade is that you get no cache busting. Import an image from src/ and Vite hashes it; drop one in public/ and the URL is stable forever. When you change your logo, returning visitors keep seeing the old tab icon until their cache gives up. Live with it, or append a version query string in the link tag and bump it on redesigns.
index.html — project root, plain absolute paths
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" sizes="16x16 32x32 48x48" />
<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" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" />
<title>Your App</title>
</head>
<body><div id="root"></div><script type="module" src="/src/main.jsx"></script></body>
</html>Create React App: public/index.html and the %PUBLIC_URL% prefix
CRA keeps index.html inside public/ and treats it as a template. Before writing the built HTML it substitutes %PUBLIC_URL% for whatever the app is actually served from, which is the homepage field in package.json or the PUBLIC_URL environment variable. That indirection exists so a CRA build can be dropped into a subdirectory without every asset path breaking, and it means a bare href="/favicon.ico" is wrong in a CRA project deployed anywhere other than the domain root.
A fresh CRA project ships three files you should delete: favicon.ico with the React atom, logo192.png and logo512.png. The generated manifest.json points at those last two, so replace the entries there as well or you will ship an installable app whose home-screen icon is still the React logo. This is the single most common half-finished favicon swap in React projects.
CRA has been unmaintained for a while and its docs still recommend a longer legacy icon list. Ignore that. The seven files from this generator cover every platform that currently requests anything, and the shorter head keeps the render-blocking work down.
public/index.html — CRA, with the build-time placeholder
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" sizes="16x16 32x32 48x48" /> <link rel="icon" type="image/png" sizes="32x32" href="%PUBLIC_URL%/favicon-32x32.png" /> <link rel="icon" type="image/png" sizes="16x16" href="%PUBLIC_URL%/favicon-16x16.png" /> <link rel="apple-touch-icon" sizes="180x180" href="%PUBLIC_URL%/apple-touch-icon.png" /> <link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
One HTML document, every route
A React SPA ships exactly one HTML file. React Router, TanStack Router and every other client-side router swap components underneath a shell that was already sent to the browser, so the head you wrote in index.html is the head for every URL in your app. There is no per-route favicon to configure and no reason to reach for react-helmet-async to set one.
Where a helmet library does earn its place is a favicon that changes at runtime — an unread-count badge, a red dot during a build failure, a light and dark variant. That is a DOM mutation, not a build concern: find the existing link element and repoint its href. Keep a canonical tag in index.html as the fallback so the icon is correct before your JavaScript has parsed.
Swapping the icon at runtime
function setFavicon(href) {
const link = document.querySelector("link[rel~='icon']");
if (link) link.href = href;
}
// e.g. respond to the user's colour scheme
const dark = window.matchMedia('(prefers-color-scheme: dark)');
setFavicon(dark.matches ? '/favicon-dark.ico' : '/favicon.ico');What to check after the build
Run the production build and inspect the output directory rather than trusting the dev server. In Vite that is dist/, in CRA it is build/. The seven files should be sitting at the top level of that folder, and the emitted index.html should contain your link tags with the paths already resolved — no leftover %PUBLIC_URL% strings, which would mean the template was copied rather than processed.
Then serve the build locally and request /favicon.ico directly in the address bar. That single check catches the two failure modes that matter: a file that never got copied, and a host rewriting unknown paths to index.html so the browser receives HTML where it expected an icon and quietly falls back to the grey globe.
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 React tab near you.
