Why Your Next.js Favicon Isn't Showing (And the Three Ways to Actually Fix It)
I had a favicon.ico. I had a favicon.svg. I had a <link> tag. I had all the pieces. The browser tab was still blank. Here's exactly what I got wrong, why Next.js App Router makes this confusing, and the right way to do it — with the actual favicon that triggered this whole thing.
Series: VIBE.LOG
- 1. The Layout Vocabulary Cheat Sheet: What to Call That Thing on Your Screen
- 2. I Spent 3 Hours Trying to Proxy a Blog Subdomain. Here's My Descent Into Madness.
- 3. The Complete SEO Guide: How to Make Google Actually Notice Your Website
- 4. Why Your Next.js Favicon Isn't Showing (And the Three Ways to Actually Fix It) ← you are here
- 5. GitHub Keeps Telling Me My Branch Is Fine. And Also Not Fine. At the Same Time.
- 6. Mobile-First Playground: Making an Astrology Grid Actually Work on a Phone (And Go Viral While Doing It)
- 7. Playground Is Live: The Destiny Grid, Real Astrology, and Why I'm Shipping a Toy Every Month
- 8. The Interactive Component Cheat Sheet: What to Call That Clickable Thing
- 9. Google Rejected My Site for 'Low-Value Content.' Here's What I Actually Fixed.
- 10. I Actually Fixed Everything. Here's What That Looked Like.
- 11. I Hired 131 AI Employees Today. Here's How.
- 12. I Let My AI Run 72 Backtests While I Watched. It Picked the Winner.
- 13. I Taught My AI to Stop Asking Questions. It Took Five Rewrites.
- 14. Obsidian Turned My Scattered Notes Into a Second Brain. Here's How to Set It Up.
- 15. The Destiny Grid Gets Its East Wing: I Rebuilt Saju (四柱八字) in TypeScript
- 16. Molecule Me: Your Personality, Encoded in Chemistry
I had a favicon.ico. I had a favicon.svg. I had <link> tags. I had all the pieces.
The browser tab was still showing a blank square.
This is the gradient V that should have been showing up — and wasn't:
What you're seeing above is the SVG file itself, rendered inline in this post — the exact same file that lives at src/app/icon.svg in the codebase. Not a screenshot, not an export. The real thing.
And yet for a while, none of it was showing up in the browser tab. Here's why.
📋 The Setup That Seemed Perfectly Fine
The site runs on Next.js 14 with the App Router. The src/app/layout.tsx had a <head> section with what I thought was comprehensive favicon coverage:
// layout.tsx — looks completely reasonable
export default function RootLayout({ children }) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="icon" href="/favicon.ico" sizes="any" />
{/* fonts, adsense, etc. */}
</head>
<body>{children}</body>
</html>
);
}And the file structure:
src/
app/
favicon.ico ← the actual .ico, 4-icon format (16px + 32px)
layout.tsx
public/
favicon.svg ← the SVG version referenced by the <link> tagTwo <link> tags, two files, both pointing to real assets. The mental model was: SVG for modern browsers, ICO as fallback. Clean. Standard. Completely broken.
💥 Three Things Happening Simultaneously
Here's what I didn't know: Next.js App Router treats src/app/favicon.ico as a special file. When that file exists in the src/app/ directory, Next.js automatically does two things:
- Serves it via a dedicated route at
/favicon.ico - Injects a
<link>tag into every page's<head>— automatically, invisibly, without you doing anything
So the actual rendered HTML <head> wasn't what I thought it was:
<!-- Auto-injected by Next.js from src/app/favicon.ico -->
<link rel="shortcut icon" href="/favicon.ico">
<!-- From the manual <link> tags in layout.tsx -->
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="icon" href="/favicon.ico" sizes="any" />Three favicon declarations. Two of them pointing to the same /favicon.ico. The browser now has to decide what to do with conflicting instructions — and different browsers handle this differently.
Chrome tends to use the last matching declaration. Firefox has its own priority logic. Safari does a third thing entirely. In practice, the result across all three is: unpredictable. Which in real life means the tab icon sometimes shows, sometimes doesn't, and you spend 15 minutes refreshing in incognito mode wondering if you're losing your mind.
🔑 The rel="shortcut icon" vs rel="icon" Wrinkle
While debugging, I noticed something else: Next.js auto-generates rel="shortcut icon" while my manual tags used rel="icon". These are not the same attribute value.
rel="shortcut icon" is a legacy format from the IE era. Modern browsers support it for backwards compatibility, but it's not the current standard. rel="icon" is what you should use today. The problem: they don't cleanly cancel each other out in any browser's priority rules.
So the browser wasn't just seeing three competing favicon declarations — it was seeing two different rel types, with one of them appearing twice. The spec doesn't have clean rules for resolving this mess. The browser just does something, and "something" isn't always "show the icon."
🛠️ How Next.js App Router Actually Handles Favicons
There are three legitimate approaches. The mistake is using more than one at the same time.
Method 1: The File Convention (Recommended)
Drop files with specific names into src/app/ and Next.js handles everything:
src/
app/
favicon.ico → <link rel="shortcut icon" href="/favicon.ico">
icon.svg → <link rel="icon" href="/icon.svg" type="image/svg+xml">
icon.png → <link rel="icon" href="/icon.png" type="image/png">
apple-icon.png → <link rel="apple-touch-icon" href="/apple-icon.png">No metadata.icons. No <link> tags. No configuration. Next.js detects these filenames at build time and generates the correct tags automatically. This is the cleanest approach because there's nothing to conflict — the framework owns the entire favicon pipeline.
Method 2: The metadata.icons API
If you want to declare icons in code:
// layout.tsx
import type { Metadata } from "next";
export const metadata: Metadata = {
icons: {
icon: [
{ url: "/favicon.svg", type: "image/svg+xml" },
{ url: "/favicon.ico", sizes: "any" },
],
apple: "/apple-touch-icon.png",
},
// ...rest of metadata
};The icons live in public/ and get served as static assets. Next.js generates the <link> tags from the metadata declaration.
Critical: if you use metadata.icons, do not also have src/app/favicon.ico. The file convention auto-generates its own <link> on top of what metadata.icons generates. You'll be back to three declarations.
Method 3: Manual <link> Tags
<head>
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="icon" href="/favicon.ico" sizes="any" />
</head>This is the correct approach for plain HTML or frameworks that don't intercept <head>. In Next.js App Router, don't use this for favicons. The framework is already managing the <head> — layering manual declarations on top creates the exact conflict described above.
✅ The Fix: Commit Fully to the File Convention
The resolution was to pick one method and remove everything else. The file convention was the cleanest choice:
Before:
src/app/favicon.ico ← auto-generates a link (invisibly, behind the scenes)
public/favicon.svg ← referenced by manual <link> in layout.tsx
layout.tsx:
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="icon" href="/favicon.ico" sizes="any" />After:
src/app/favicon.ico ← auto-handled ✓
src/app/icon.svg ← auto-handled ✓
layout.tsx:
(no favicon <link> tags)The SVG moved from public/favicon.svg into src/app/icon.svg — the file convention path. The two manual <link> tags were deleted. The rendered <head> now contains exactly:
<link rel="shortcut icon" href="/favicon.ico">
<link rel="icon" href="/icon.svg" type="image/svg+xml">Two declarations, zero conflicts. The result in a browser tab:
That's the actual favicon, the actual tab. No screenshot trickery — it's the same SVG, rendered at 14px in a mocked browser tab. What you see in a real Chrome tab is exactly this.
🧠 The Broader Mistake: Invisible Framework Behavior
The root cause wasn't a wrong file path or a typo. It was adding manual configuration on top of automatic configuration without knowing the automatic configuration existed.
Next.js App Router has a lot of conventions that operate silently:
src/app/page.tsx→ automatically the root routesrc/app/loading.tsx→ automatically a Suspense loading statesrc/app/error.tsx→ automatically an error boundarysrc/app/favicon.ico→ automatically a favicon with an auto-injected<link>tag
None of these generate warnings when you double-up. You don't get a console message saying "hey, you've declared a favicon three times, this might not work." The framework does its thing. Your manual code does its thing. The browser tries to reconcile the result and sometimes gives up quietly.
This pattern shows up constantly when adopting a framework with strong conventions. You write the manual version of something because that's how you did it before — or because some Stack Overflow answer from 2021 shows the manual approach. Meanwhile the framework is doing the same thing automatically. Both are valid. They conflict. You get silence instead of an error.
The safest habit: before manually configuring anything in Next.js App Router, check whether the framework already handles it by file convention. The special files reference lists every filename that triggers automatic behavior. favicon.ico and icon.{jpg,png,svg} are on that list. Reading it once would have saved this particular fifteen minutes.
📝 The Actual Diff
The full change from today's commit:
# src/app/layout.tsx
- <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
- <link rel="icon" href="/favicon.ico" sizes="any" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
# src/app/icon.svg — new file (moved from public/favicon.svg)
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
+ <defs>
+ <linearGradient id="vg" x1="5" y1="6" x2="27" y2="26" gradientUnits="userSpaceOnUse">
+ <stop offset="0" stop-color="#FF8538"/>
+ <stop offset="0.42" stop-color="#FC0E94"/>
+ <stop offset="1" stop-color="#8823BD"/>
+ </linearGradient>
+ <radialGradient id="bg" cx="50%" cy="35%" r="65%">
+ <stop offset="0" stop-color="#2A1F2D"/>
+ <stop offset="100%" stop-color="#141210"/>
+ </radialGradient>
+ </defs>
+ <rect width="32" height="32" rx="6.5" fill="url(#bg)"/>
+ <path d="M5 6.5 L10.2 6.5 L16 21.5 L21.8 6.5 L27 6.5 L16 26.5 Z" fill="url(#vg)"/>
+ <circle cx="16" cy="3.2" r="1.5" fill="#FC0E94" opacity="0.9"/>
+ </svg>Two lines deleted. One file moved. Commit message: fix: resolve favicon not appearing in browser tab.
The fix was fifteen minutes. Understanding the root cause took the rest of the afternoon.
✅ Favicon Setup Checklist for Next.js App Router
If you're auditing an existing project or setting one up fresh:
- Open DevTools → Elements →
<head>and count<link rel="icon">and<link rel="shortcut icon">entries - If there's more than one per format, you have a conflict — find all sources and remove duplicates
- Choose exactly one method: file convention,
metadata.icons, or manual tags - If using file convention: place files in
src/app/with the exact names Next.js expects - If using
metadata.icons: keep icon files inpublic/, remove anysrc/app/favicon.ico - If using manual
<link>tags: you're working against the framework — switch to one of the above - Verify in an incognito window — browsers cache favicons aggressively, sometimes for days
- Test in Chrome, Firefox, and Safari separately — their fallback priority rules differ
The incognito test is the one people skip. A favicon that "still looks broken" after a fix might just be a cached blank icon from before. Always verify in a clean session before assuming the fix didn't work.
The gradient V is now in the tab. It was there all along — the file was correct, the path was correct, the SVG was valid. The framework just needed one set of instructions, not three overlapping ones.
Written by
Jay
Licensed Pharmacist · Senior Researcher
Building production-grade AI tools across medicine, finance, and productivity — without a CS degree. Domain expertise first, code second.
About the author →Related posts