Favicon Setup for Vue — Free, Fast & Private
Vue itself has no opinion about favicons — there is no Vue API for them, and no single-file component can declare one, because SFCs render into the body long after the head was parsed. Everything here is a property of the build tool underneath: Vite for anything scaffolded with create-vue, webpack for the older Vue CLI projects still in service.
Where each generated file goes in Vue
The generator above emits seven files with fixed names. Static assets in Vue live in public/, and the markup is written in index.html (create-vue) or public/index.html (Vue CLI). This is the mapping between the two.
| Generated file | Goes to | Notes |
|---|---|---|
| favicon.ico | public/favicon.ico | Replaces the Vue logo favicon the scaffold ships. |
| favicon-16x16.png | public/favicon-16x16.png | Explicit link tag in index.html. |
| favicon-32x32.png | public/favicon-32x32.png | Explicit link tag in index.html. |
| favicon-48x48.png | public/favicon-48x48.png | Optional — the .ico already carries this size. |
| apple-touch-icon.png | public/apple-touch-icon.png | iOS home screen, 180×180. |
| android-chrome-192x192.png | public/android-chrome-192x192.png | Manifest only. |
| android-chrome-512x512.png | public/android-chrome-512x512.png | Manifest only, PWA splash. |
create-vue: root index.html, absolute paths
A project scaffolded with npm create vue@latest is a Vite project with Vue plugins attached. index.html sits at the project root and is the true entry point of the build — Vite parses it, follows the module script into src/main.js, and rewrites what it needs to. public/ is the untouched-assets folder beside it, copied to dist/ verbatim.
That means you reference favicons with a plain absolute path and nothing clever happens to them. The scaffold already ships public/favicon.ico with the Vue logo and a single link tag pointing at it, so the minimum viable change is overwriting that one file. Adding the PNG tags is what actually earns you a sharp icon on a retina tab and a real icon on an iOS home screen.
Vite deliberately does not fingerprint public/ files, so the URLs are stable and the browser cache is durable. If you rebrand, the honest options are versioning the filename or appending a query string to the href — there is no build step that will do it for you.
index.html — create-vue / Vite
<!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="app"></div><script type="module" src="/src/main.js"></script></body>
</html>Vue CLI: public/index.html and the BASE_URL placeholder
Projects built on vue-cli-service keep index.html inside public/ and run it through an HTML template step before emitting it. The generated file uses an EJS-style placeholder for the deployment path, and the scaffolded favicon tag looks like href="<%= BASE_URL %>favicon.ico" rather than a bare slash. That placeholder resolves to the publicPath value from vue.config.js, which is why a Vue CLI build can be dropped into a subdirectory without every asset breaking.
The trap is copy-pasting a snippet from somewhere else into that file. A hardcoded /favicon.ico looks identical in local development, where publicPath is "/", and 404s the moment the app is deployed under a path. If your project has a public/index.html, keep the placeholder on every icon href.
Vue CLI is in maintenance mode and the Vue docs now point new projects at create-vue. If you are migrating, the favicon work is one of the easy parts: the files stay in public/, index.html moves up one directory, and every <%= BASE_URL %> becomes a plain leading slash.
public/index.html — Vue CLI template syntax
<link rel="icon" href="<%= BASE_URL %>favicon.ico" sizes="16x16 32x32 48x48"> <link rel="icon" type="image/png" sizes="32x32" href="<%= BASE_URL %>favicon-32x32.png"> <link rel="icon" type="image/png" sizes="16x16" href="<%= BASE_URL %>favicon-16x16.png"> <link rel="apple-touch-icon" sizes="180x180" href="<%= BASE_URL %>apple-touch-icon.png"> <link rel="manifest" href="<%= BASE_URL %>site.webmanifest">
When the icon has to change at runtime
Some Vue apps want a live favicon: an unread badge on a messaging view, a red dot while a job is failing, a light and dark variant that tracks the OS theme. None of that belongs in index.html. Reach for useHead from unhead — the same package Nuxt uses internally, and installable in a plain Vue app through @unhead/vue — or just mutate the existing link element, which is a couple of lines and no dependency.
Whichever route you take, leave a static tag in index.html. It is what the browser sees during the first paint, before your bundle has parsed, and on the many occasions when a bot or a link preview service fetches the HTML without executing any JavaScript at all.
useHead in a Vue component
import { useHead } from '@unhead/vue';
import { computed } from 'vue';
const unread = ref(0);
useHead({
link: [{
rel: 'icon',
href: computed(() => (unread.value > 0 ? '/favicon-badge.png' : '/favicon-32x32.png')),
}],
});Vitepress, Nuxt, and other Vue-shaped things
Not everything written in Vue is a Vue app in the sense this page means. A VitePress documentation site has no index.html of yours to edit — you put the files in the .vitepress/public directory and declare the tags through the head array in .vitepress/config.js, because the HTML is generated per page from your markdown.
Nuxt is further away still: public/ is the right folder but the head is configured from nuxt.config.ts and from useHead calls, never from a checked-in HTML file. If your project has a nuxt.config file, follow the Nuxt instructions instead of these — the file placement happens to match, and everything about the markup does not.
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 Vue tab near you.
