How to Add a Favicon to a Vue or Nuxt App (The Right Way)
Where favicon files go in a Vite-powered Vue project and in Nuxt, how to declare them without fighting the build, and why the default Vite favicon keeps coming back.
Quick answer: put your favicon files in the public/ directory — public/favicon.ico, public/apple-touch-icon.png, and so on. In a plain Vue + Vite app, declare them in index.html. In Nuxt, declare them in app.head.link inside nuxt.config.ts (or with useHead() for a single page). Reference them with absolute paths starting with /, never relative ones.
Both stacks make this easy once you know which directory is copied verbatim and which one is processed by the bundler. Nearly every "my Vue favicon isn't working" thread comes down to putting the file in the wrong one.
The one rule: public/ is copied, src/assets/ is processed
Vite treats these two directories completely differently.
| Directory | What happens at build time | Use it for |
|---|---|---|
public/ |
Copied to the root of dist/ untouched, same filename |
Favicons, robots.txt, manifest.webmanifest |
src/assets/ |
Processed, hashed, inlined or renamed | Images imported by components |
A favicon has to keep its exact filename and live at a predictable URL, because browsers and crawlers request /favicon.ico directly without reading your HTML. The moment the bundler renames it to favicon-a3f9c2.ico, that request 404s. So: public/, always.
Vue 3 with Vite (no framework)
Drop the exported files into public/:
public/
├── favicon.ico
├── favicon-16x16.png
├── favicon-32x32.png
├── apple-touch-icon.png
├── icon-192.png
├── icon-512.png
└── site.webmanifest
Then open index.html at the project root — not inside src/ — and replace the default Vite line with the full set:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="/favicon.ico" sizes="32x32" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" />
<meta name="theme-color" content="#ffffff" />
<title>Your app</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
That's the whole job for a plain Vue app. There is no Vue-specific API involved, because the favicon is declared in the HTML shell that Vue mounts into, not by Vue itself.
Why the Vite logo keeps coming back
A scaffolded Vite project ships with public/vite.svg and this line in index.html:
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
If you add your own icon but leave that line in place, you now have two competing icon declarations and the browser picks whichever it prefers — which is frequently the SVG. Delete the line and delete public/vite.svg. Leaving the file behind is harmless in theory, but it makes the next debugging session much more confusing when you find it still being served.
Nuxt
Nuxt also has a public/ directory that's served from the site root, so the files go in exactly the same place:
public/
├── favicon.ico
├── apple-touch-icon.png
├── icon-192.png
├── icon-512.png
└── site.webmanifest
Nuxt has no index.html for you to edit, though — it generates the document. Declare the tags in nuxt.config.ts so they apply to every route:
export default defineNuxtConfig({
app: {
head: {
link: [
{ rel: 'icon', href: '/favicon.ico', sizes: '32x32' },
{ rel: 'icon', type: 'image/png', sizes: '32x32', href: '/favicon-32x32.png' },
{ rel: 'icon', type: 'image/png', sizes: '16x16', href: '/favicon-16x16.png' },
{ rel: 'apple-touch-icon', href: '/apple-touch-icon.png' },
{ rel: 'manifest', href: '/site.webmanifest' },
],
meta: [{ name: 'theme-color', content: '#ffffff' }],
},
},
})
This is the right place for icons specifically, because they should be identical on every page. useHead() exists for per-page metadata — titles, descriptions, canonical URLs — and using it for favicons means re-declaring the same tags on every route for no benefit.
If you're using the Nuxt SEO or PWA modules
Some modules generate icon tags for you. @vite-pwa/nuxt, for example, writes its own manifest and can inject icon links based on your PWA config. If you also hand-write the tags above, you can end up with duplicates pointing at different files.
Pick one owner:
- Module owns it: configure icons in the module's options and don't declare them in
app.head. - You own it: declare them in
app.headand disable the module's icon injection.
The Checker makes this easy to confirm — run it against your deployed URL and it lists every icon tag actually present in the served HTML, which is the only way to be sure a module hasn't quietly added a second set.
Deploying with a base path
If your app is deployed under a subpath — GitHub Pages project sites are the usual case — absolute paths like /favicon.ico will resolve to the domain root instead of your app root, and 404.
In Vite, set the base:
// vite.config.ts
export default defineConfig({
base: '/my-project/',
})
Vite rewrites the asset URLs in index.html accordingly. In Nuxt the equivalent is app.baseURL:
export default defineNuxtConfig({
app: {
baseURL: '/my-project/',
head: {
link: [{ rel: 'icon', href: '/my-project/favicon.ico', sizes: '32x32' }],
},
},
})
Note that values you hand-write inside head.link are used as-is — Nuxt won't prefix them with baseURL for you. This is a genuinely common source of missing icons on project-site deployments.
Verifying it worked
Do these in order; each one rules out a different cause.
- Request the file directly. Open
https://yoursite.com/favicon.icoin a new tab. If that 404s or renders your app's HTML instead of an image, the file isn't inpublic/or your host is serving the SPA fallback for unknown paths — fix that before touching anything else. - View source, not the inspector. Use
view-source:orcurlrather than DevTools' Elements panel. The Elements panel shows the DOM after any client-side changes; view-source shows what was actually served, which is what crawlers see. - Hard reload, then try a private window. Favicons are cached far more aggressively than other assets, and a normal refresh usually won't replace one. A private window is the fastest way to see the true first-visit result.
- Check the response content type. A
favicon.icoserved astext/htmlwill not render. This is usually a host or rewrite-rule problem, not an application one.
If it's still wrong after all four, the Checker will tell you exactly which icons your deployed site declares and whether each one loads — which is faster than guessing.
The short version
- Favicon files go in
public/in both Vue and Nuxt. Neversrc/assets/. - Plain Vue: declare tags in
index.html, and delete the defaultvite.svgline and file. - Nuxt: declare them once in
nuxt.config.tsunderapp.head.link. - Watch out for a PWA or SEO module already injecting icon tags.
- With a base path, write the prefix into your hand-authored hrefs yourself.
You can generate the full file set — .ico, PNG sizes, Apple touch icon and manifest — from any image, emoji or piece of text with the Generator, which also outputs the exact <head> snippet to paste into index.html or convert into the Nuxt config array 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.