Favicon Setup for Svelte — Free, Fast & Private
The name of the folder is configurable through the files.assets option in svelte.config.js, but almost nobody changes it, and every SvelteKit tutorial assumes static/. If you have been pasting instructions written for Vite or Next and wondering why nothing is being served, the folder you created is probably named public/ and is being ignored.
Where each generated file goes in Svelte
The generator above emits eight files with fixed names, site.webmanifest included. Static assets in Svelte live in static/, and the markup is written in src/app.html. This is the mapping between the two.
| Generated file | Goes to | Notes |
|---|---|---|
| favicon.ico | static/favicon.ico | Note the folder name — static/, not public/. |
| favicon-16x16.png | static/favicon-16x16.png | Linked from app.html. |
| favicon-32x32.png | static/favicon-32x32.png | Linked from app.html. |
| favicon-48x48.png | static/favicon-48x48.png | Optional loose copy of an .ico entry. |
| apple-touch-icon.png | static/apple-touch-icon.png | Replaces the default favicon.png the skeleton ships. |
| android-chrome-192x192.png | static/android-chrome-192x192.png | Manifest icon. |
| android-chrome-512x512.png | static/android-chrome-512x512.png | Manifest icon and splash. |
| site.webmanifest | static/site.webmanifest | static/, not public/ — same folder as the icons, which is what its relative paths rely on. |
src/app.html is a template, not a page
Every SvelteKit response is rendered into src/app.html. It contains two placeholders that the framework fills in — %sveltekit.head% for anything SvelteKit or your components contributed to the head, and %sveltekit.body% for the app itself. Whatever you write directly into that head is present on every route in the application, server-rendered and prerendered alike, which makes it the right home for favicons.
The skeleton project ships one line: a link element pointing at favicon.png, prefixed with %sveltekit.assets%. Replace that single line with the full block and you are done. There is no per-route configuration to think about and no plugin to install.
The one thing not to do is put favicon markup inside a <svelte:head> block in +layout.svelte. It technically works — it lands inside %sveltekit.head% — but it now runs through the head reconciler on every client-side navigation, and it is one more thing to keep in sync if you ever add a second root layout. Reserve <svelte:head> for the tags that genuinely vary per route, like titles and canonical URLs.
src/app.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.ico" sizes="16x16 32x32 48x48" />
<link rel="icon" type="image/png" sizes="32x32" href="%sveltekit.assets%/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="%sveltekit.assets%/favicon-16x16.png" />
<link rel="apple-touch-icon" sizes="180x180" href="%sveltekit.assets%/apple-touch-icon.png" />
<link rel="manifest" href="%sveltekit.assets%/site.webmanifest" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>Why %sveltekit.assets% and not a plain slash
A bare href="/favicon.ico" works right up until the app is deployed somewhere other than the root of a domain. SvelteKit supports that case through the paths.base option in svelte.config.js — set it to "/docs" and every internal link the framework generates gets that prefix. Hardcoded absolute paths in app.html do not, because SvelteKit has no reason to parse your handwritten HTML looking for URLs to rewrite.
%sveltekit.assets% is the escape hatch. It resolves to paths.assets when you have configured an external asset host, falls back to paths.base otherwise, and becomes an empty string when neither is set — which is why the placeholder version is byte-identical to the plain version on a root deployment and correct everywhere else. Using it costs nothing and removes an entire class of staging-only bug.
The exception is favicon.ico itself, which browsers will request at the literal root path regardless of what any tag says. On a base-path deployment that request lands outside your app, so a copy of the .ico at the true server root remains worthwhile insurance if you control that server.
What adapters do with static/
Whichever adapter you use, the contents of static/ are copied into the build output and served from the root of the app. With adapter-static, the entire site is prerendered and static/ files land beside the generated HTML, so the deployed tree is exactly what you would expect from a plain static host. With adapter-node they are served by the Node server, and with the platform adapters they usually become CDN-served static files, sometimes with far-future cache headers applied automatically.
That last detail is worth a moment. SvelteKit fingerprints the JavaScript and CSS it builds and can cache them immutably, but nothing in static/ is fingerprinted, so an aggressive CDN cache rule that covers the whole output can pin your old favicon in place for a very long time. If your host lets you set per-path cache headers, favicons deserve a shorter max-age than the hashed bundles.
svelte.config.js — base path and asset host
import adapter from '@sveltejs/adapter-static';
export default {
kit: {
adapter: adapter(),
paths: {
base: '/docs', // deployed under a subpath
// assets: 'https://cdn.example.com' // optional external asset host
},
},
};Plain Svelte without Kit
If your project has no src/routes directory and no svelte.config.js with a kit block, you are on the plain Svelte + Vite template, and everything above is the wrong advice. That template follows the ordinary Vite conventions: static files go in public/, index.html sits at the project root and is edited directly, and paths are plain absolute slashes with no placeholder.
The quickest way to tell them apart is to look for src/app.html. If it exists, you are in SvelteKit and static/ is your folder. If instead there is an index.html next to package.json, you are in a Vite app that happens to compile Svelte components, and the React or Vue instructions apply verbatim.
The manifest — the skeleton does not include one
The app.html block above ends with a link to site.webmanifest, and the SvelteKit skeleton does not create that file. Left as is, the tag is a promise the deployment cannot keep: every page load requests it, and depending on your adapter the response is a 404 or the SPA fallback HTML, which the browser then fails to parse. The generator above downloads the file, so this step is a copy into static/ — static/, not public/, the same folder the icons went in — plus a change to name and short_name.
Note that the src values below carry no leading slash, and that this is the part that makes it safe under paths.base. %sveltekit.assets% is substituted in app.html and never inside a static JSON file, so a hand-written manifest with "/android-chrome-192x192.png" in it points at the domain root while the manifest itself, reached through the placeholder, resolves correctly under the base path. That mismatch is the usual reason a manifest resolves fine while its icons 404, and it surfaces only at install time on a real Android device. A relative src is resolved against the manifest's own URL, so it picks up the base prefix — or your paths.assets host — automatically. The condition is that the manifest and the icons stay in one directory, which static/ gives you for free.
static/site.webmanifest — as downloaded, edit name and short_name
{
"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 Svelte tab near you.
