← Back to blog

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.

by Jay10 min readVIBE.LOG

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:

The actual favicon — src/app/icon.svg
Purple → pink → orange → green gradient v on dark background
64×64 viewport · lowercase v from VibedLabLogo · holds up at 16px in a browser tab

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> tag

Two <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:

  1. Serves it via a dedicated route at /favicon.ico
  2. 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.

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.

<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:

Vibed Lab
https://vibed-lab.com

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 route
  • src/app/loading.tsx → automatically a Suspense loading state
  • src/app/error.tsx → automatically an error boundary
  • src/app/favicon.icoautomatically 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 in public/, remove any src/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 →
ShareX / TwitterLinkedIn