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 seven files with fixed names. 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. |
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.
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.
