Favicon Setup for Nuxt — Free, Fast & Private
Nuxt 3 renamed the Nuxt 2 static/ directory to public/, and Nuxt 4 moved application source into an app/ directory while deliberately leaving public/, server/ and the config file at the project root. So public/ is correct across every currently supported version, and it is one of the few paths a Nuxt 3 to 4 migration does not touch.
Where each generated file goes in Nuxt
The generator above emits eight files with fixed names, site.webmanifest included. Static assets in Nuxt live in public/ (project root, even in Nuxt 4), and the markup is written in nuxt.config.ts — app.head.link. This is the mapping between the two.
| Generated file | Goes to | Notes |
|---|---|---|
| favicon.ico | public/favicon.ico | Overwrites the Nuxt logo the scaffold ships. |
| favicon-16x16.png | public/favicon-16x16.png | Declared in app.head.link. |
| favicon-32x32.png | public/favicon-32x32.png | Declared in app.head.link. |
| 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 only, read by the downloaded manifest or by @vite-pwa/nuxt. |
| android-chrome-512x512.png | public/android-chrome-512x512.png | Manifest only, PWA splash. |
| site.webmanifest | public/site.webmanifest | Skip this one file if @vite-pwa/nuxt generates a manifest — two manifests is the duplication trap. |
The head is configuration, not markup
There is no index.html in a Nuxt project. The document is assembled at request time — or at generate time — from an object, and the canonical place to put site-wide head entries is the app.head block in nuxt.config. Each link is a plain object whose keys become attributes, which reads oddly the first time and pays off immediately: it is data you can compose, spread from a shared constant, or generate from the same list that drives your manifest.
Everything declared there applies to every route with no per-page work. Because it is evaluated at build time it also lands in the server-rendered HTML, so crawlers and link preview services that never run JavaScript still see the icons. That is a real advantage over the runtime-only approach of setting them from a component.
The useHead composable exists for the tags that genuinely vary — a page title, a canonical URL, an Open Graph image tied to a specific article. You can override the icon from a page with it, and Nuxt will reconcile the duplicate link entries rather than emitting both, but that is a niche need. Favicons belong in the config.
nuxt.config.ts
export default defineNuxtConfig({
app: {
head: {
link: [
{ rel: 'icon', href: '/favicon.ico', sizes: '16x16 32x32 48x48' },
{ 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', sizes: '180x180', href: '/apple-touch-icon.png' },
{ rel: 'manifest', href: '/site.webmanifest' },
],
},
},
});public/ stays at the project root, including in Nuxt 4
Nuxt 4 restructured the project: components, pages, layouts and composables moved under an app/ directory so that editor tooling and file watchers stop tripping over server code and node_modules. What did not move is public/. It remains a sibling of nuxt.config.ts at the project root, alongside server/ and any modules directory.
This catches people mid-migration who dutifully move everything into app/ and then find their icons 404ing. It also catches anyone following a Nuxt 2 tutorial, where the folder was called static/ — that name has been dead since Nuxt 3 and a static/ directory in a modern project is simply not served.
Files in public/ are copied verbatim into the build output with no hashing, which is why an absolute path in the config is stable. That also means no automatic cache busting, so a rebrand needs either a filename change or a version query string on the href.
Project layout — Nuxt 4
my-app/
app/ ← pages, components, layouts, composables
app.vue
pages/
public/ ← still at the root, NOT inside app/
favicon.ico
apple-touch-icon.png
android-chrome-192x192.png
android-chrome-512x512.png
site.webmanifest
server/
nuxt.config.tsDeploying under a base URL
Nuxt serves the app from app.baseURL, which defaults to "/" and is commonly overridden through the NUXT_APP_BASE_URL environment variable when a site is published under a path. Absolute hrefs written into the config are not rewritten by that setting, so a hardcoded /favicon.ico misses on a prefixed deployment.
The composable useRuntimeConfig().app.baseURL gives you the value at runtime, and joinURL from the ufo package that ships with Nuxt joins it cleanly without doubled slashes. If your deployment is always at the root, plain absolute paths are fine and simpler — just make the choice knowingly rather than discovering it in staging.
Composing hrefs against a base URL
// app.vue — when the site may be served under a path prefix
import { joinURL } from 'ufo';
const base = useRuntimeConfig().app.baseURL;
useHead({
link: [
{ rel: 'icon', href: joinURL(base, 'favicon.ico'), sizes: '16x16 32x32 48x48' },
{ rel: 'apple-touch-icon', sizes: '180x180', href: joinURL(base, 'apple-touch-icon.png') },
],
});Build outputs, and where the files actually land
nuxt build produces a Nitro server bundle in .output/, with the static assets under .output/public/ — that directory is what a CDN or static edge layer serves, and your icons will be at its top level. nuxt generate prerenders every route and produces a fully static tree under .output/public/ as well, which you can upload anywhere. Either way, checking that directory after a build is the fastest way to confirm the files were picked up.
If you are using @vite-pwa/nuxt or the Nuxt PWA module, be aware that it generates its own manifest and may inject its own icon link tags, which can duplicate the ones in your config. Configure the module with your android-chrome PNGs rather than declaring the manifest twice, and check the rendered head afterwards.
The manifest, unless a PWA module is writing it for you
The link array above declares a manifest at /site.webmanifest. On a plain Nuxt project nothing creates that file — public/ is copied verbatim and Nuxt adds nothing to it — so the tag points at a path that returns a 404 on every route in the app. Decide which of the two owns it and do only that one. If you are not running a PWA module, the generator above downloads the file: drop it into public/ beside the icons and change name and short_name.
If you are running @vite-pwa/nuxt, do the opposite: skip the downloaded file entirely, delete the manifest entry from app.head.link, and configure the module with the two android-chrome PNGs instead. The module generates a manifest and injects its own link tag, so shipping ours as well produces two competing manifests in the rendered head and the browser picks one. That is the same duplication trap described above, and it is the one case in this whole cluster where the right move is not to use the file the tool gives you.
One thing the downloaded file does buy you on the plain route: its icon paths are relative, so they follow the manifest under app.baseURL. Absolute hrefs in nuxt.config are not rewritten by that setting — the section above says so about the icon links — and a hand-written manifest is worse, because no build value reaches inside a static JSON file at all. Relative src values are resolved against the manifest URL, so a NUXT_APP_BASE_URL of "/app/" carries them along with it, as long as the manifest stays in public/ with the PNGs.
public/site.webmanifest — as downloaded (skip it entirely if a PWA module generates one)
{
"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 Nuxt tab near you.
