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 seven files with fixed names. 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 @vite-pwa/nuxt or a hand-written manifest. |
| android-chrome-512x512.png | public/android-chrome-512x512.png | Manifest only, PWA splash. |
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.
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.
