Build-time favicon generation with real-favicon: deterministic assets without framework lock-in
TL;DR
Favicons look like a minor detail until they start drifting across environments. One project has afavicon.ico, another has a half-finished manifest, and a third has some build plugin mutating HTML at runtime. I wanted something simpler: generate the files once, write them to disk, and make the application consume them explicitly. That is the reasonreal-faviconexists.
Favicons are usually treated as an afterthought, but they touch more surface area than most people expect: browser tabs, pinned shortcuts, mobile home screens, web app manifests, and theme colors.
The image generation problem is already solved by the upstream packages real-favicon wraps: @realfavicongenerator/generate-favicon and @realfavicongenerator/image-adapter-node. If you want the broader upstream project behind them, the official website is here: RealFaviconGenerator.
What was missing, at least for my use case, was the orchestration around it:
- when generation should run
- where files should be written
- how the resulting HTML should be consumed
- how to keep the process deterministic in CI and local builds
That led me to build @jturbide/real-favicon, with the source available on GitHub, a small wrapper around the official toolchain with a deliberately narrow job: turn a source image and a config object into static favicon assets plus a static HTML fragment.
The real problem is not favicon generation
If all you need is a single favicon.ico, you can stop here. The problem appears when you want the full set of modern favicon outputs and you want them to behave like any other build artifact.
Manual generation is annoying for obvious reasons. The more subtle issue is that runtime or framework-coupled solutions often blur responsibilities:
- they hide when generation happens
- they inject markup automatically
- they couple asset generation to one framework lifecycle
- they make CI and debugging less obvious than they should be
For favicons, I think that is the wrong tradeoff.
Favicon generation should happen before the application build, write explicit files, and stop there. The app should only consume the output.
What real-favicon actually adds
real-favicon does not reimplement image processing. It delegates that to the upstream @realfavicongenerator/generate-favicon and @realfavicongenerator/image-adapter-node packages and focuses on orchestration:
- static asset generation at build time
- deterministic file output
- deterministic HTML
<link>and<meta>markup - a framework-agnostic core
- thin adapters for Node scripts and Vite
- lifecycle hooks for logging and observability
That narrow scope is the whole point. It is supposed to be boring infrastructure.
The workflow I wanted in a real project
On this site, favicon generation is part of the pre-build pipeline, alongside metadata, sitemap, and image generation.
The package itself is available on npmx.dev for @jturbide/real-favicon, and the source lives on GitHub.
The package installation is intentionally unremarkable:
yarn add -D @jturbide/real-favicon Then the project wires it into a script:
{
"scripts": {
"generate:favicon": "tsx scripts/generate-favicon.ts",
"prebuild": "tsx scripts/prebuild.ts",
"build": "vite build"
}
} And scripts/prebuild.ts calls it as part of the full asset pipeline:
import {execSync} from 'node:child_process'
for (const cmd of [
'yarn generate:assets',
'yarn optimize:assets',
'yarn generate:metadata',
'yarn generate:favicon',
'yarn generate:og-image',
'yarn generate:sitemap',
'yarn generate:llms',
'yarn generate:rss',
]) {
execSync(cmd, {stdio: 'inherit'})
} That means the favicon step runs before SvelteKit or Vite starts building the application itself. By the time the app is rendered, the generated favicon files and HTML already exist.
A practical generation script
This is the shape I actually want to work with: one source image, one config object, one explicit output directory.
import {generateFaviconsNode} from '@jturbide/real-favicon'
import {IconTransformationType} from '@realfavicongenerator/generate-favicon'
await generateFaviconsNode({
skipIfExists: false,
copyIcoToRoot: true,
source: './src/lib/assets/images/jturbide-logo-140x160.png',
htmlOutput: './src/lib/generated/favicons.html',
outputDir: './static/favicons',
rootDir: './static',
path: '/favicons/',
icon: {
desktop: {
regularIconTransformation: {
type: IconTransformationType.Background,
backgroundColor: '#ffffff',
backgroundRadius: 0.4,
imageScale: 1,
brightness: 0,
},
darkIconTransformation: {
type: IconTransformationType.Background,
backgroundColor: '#ffffff',
backgroundRadius: 0.4,
imageScale: 1,
brightness: 0,
},
darkIconType: 'none',
},
touch: {
transformation: {
type: IconTransformationType.Background,
backgroundColor: '#ffffff',
backgroundRadius: 0,
imageScale: 1,
brightness: 0,
},
appTitle: 'jTurbide',
},
webAppManifest: {
transformation: {
type: IconTransformationType.Background,
backgroundColor: '#ffffff',
backgroundRadius: 0,
imageScale: 0.7,
brightness: 0,
},
backgroundColor: '#ffffff',
themeColor: '#39464a',
name: 'Julien Turbide',
shortName: 'jTurbide',
},
},
}) There are a few design choices here that matter:
sourcepoints at a normal project assetoutputDiris where generated files are writtenhtmlOutputis a static fragment, not magic injected statepathcontrols public URLs independently from the filesystemhookscan be added for logging without coupling the library to any logger
The result is deterministic and inspectable. If something goes wrong, there is no mystery about where the files should be or how they got there.
What the application consumes
One of the nicest parts of this approach is that the application does not need to know anything about favicon generation. It only consumes a static HTML fragment.
The generated output looks roughly like this:
<link rel="icon" type="image/svg+xml" href="/favicons/favicon.svg" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicons/favicon-32x32.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/favicons/apple-touch-icon.png" />
<link rel="manifest" href="/favicons/site.webmanifest" />
<meta name="theme-color" content="#39464a" /> That is exactly what I want: plain old markup, ready to be inserted where the framework expects head content.
SvelteKit integration stays explicit
For SvelteKit, the generated HTML is imported as a raw file and injected during page rendering.
import type {Handle} from '@sveltejs/kit'
import {sequence} from '@sveltejs/kit/hooks'
import {paraglideMiddleware} from '$lib/paraglide/server'
import favicons from '$lib/generated/favicons.html?raw'
const paraglideHandle: Handle = ({event, resolve}) =>
paraglideMiddleware(event.request, ({request: localizedRequest, locale}) => {
event.request = localizedRequest
return resolve(event, {
transformPageChunk: ({html}) => html.replace('%paraglide.lang%', locale),
})
})
const faviconHandle: Handle = ({event, resolve}) => {
return resolve(event, {
transformPageChunk: ({html}) => html.replace('%favicons.head%', favicons),
})
}
export const handle: Handle = sequence(paraglideHandle, faviconHandle) Then the app template exposes a placeholder inside <head>:
<!doctype html>
<html lang="%paraglide.lang%">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%favicons.head%
%sveltekit.head%
</head>
</html> This is explicit, readable, and easy to debug. Nothing mutates the document behind your back.
If you prefer a Vite plugin, that is available too
I still prefer the Node script approach because it is easier to reason about, especially in CI, but the Vite adapter exists if you want bundler-managed orchestration:
import {defineConfig} from 'vite'
import {realFavicon} from '@jturbide/real-favicon'
export default defineConfig({
plugins: [
realFavicon({
source: './logo.png',
outputDir: './public/favicons',
htmlOutput: './src/generated/favicons.html',
path: '/favicons/',
icon: {
// RealFaviconGenerator settings
},
}),
],
}) That adapter still follows the same rules: generate files, write HTML, avoid hidden runtime behavior.
Why this shape is useful
The practical benefits are not flashy, but they are exactly the kind of boring reliability I want from build tooling:
- identical inputs produce identical outputs
- builds can fail early instead of shipping missing assets
- CI and local environments behave the same way
- the application remains framework-native
- there is no favicon logic running in production
This is one of those cases where “less magic” genuinely leads to a better developer experience.
What it intentionally does not do
To keep the package small and predictable, real-favicon avoids a bunch of tempting features:
- no runtime generation
- no automatic HTML injection
- no framework lock-in
- no dev-server or HMR behavior
- no configuration guessing or convention inference
If you want a highly opinionated framework plugin that takes over everything, this is not that tool. If you want a deterministic favicon step that behaves like any other build artifact generator, it fits much better.
Closing thoughts
The main value of this project is not that it generates favicons. Plenty of tools can do that. The value is that it makes favicon generation fit cleanly into a modern build pipeline without hiding how anything works.
That is a small improvement, but a real one.
If you want to look at the implementation or try it in your own project, these are the most useful links:
March 25, 2026 by Julien Turbide
