Platform Guides

Next.js 16 Favicon Guide: App Router Best Practices

Modern favicon implementation for Next.js 16 using the App Router. File-based metadata API and advanced configurations.

7 min read
Free Guide

Next.js and the File-Based Metadata API

The App Router changed how favicons work in Next.js, and mostly for the better. No webpack config, no manually written link tags. Drop a file in the right place and Next.js works out the rel, type and sizes attributes for you and emits the tag.

This guide is written against Next.js 16, though everything here has been stable since 15. If you're still on the Pages Router, the concepts differ significantly - this is all about embracing the newer paradigm.

The File-Based Approach (Recommended)

Simple Favicon Setup

The easiest method requires just one step:

app/
└── favicon.ico
  1. 1Add your favicon to your app directory:

That's it. Next.js automatically:

Modern Icon Setup

  • Serves it at /favicon.ico
  • Adds the appropriate <link> tag
  • Handles caching headers
  • Optimises delivery

For better quality and device support, use the icon file convention:

app/
├── icon.png       # 32x32 favicon
├── icon.svg       # Scalable version (optional)
└── apple-icon.png # 180x180 Apple touch icon

Next.js generates all necessary tags:

<link rel="icon" href="/icon?<generated>" type="image/png" sizes="32x32" />
<link rel="apple-touch-icon" href="/apple-icon?<generated>" type="image/png" sizes="180x180" />

Advanced Icon Configuration

Multiple Sizes

Need more than one icon? Add a number suffix and Next.js will emit a tag for each:

app/
├── icon1.png     # a 32x32 image -> sizes="32x32"
├── icon2.png     # a 16x16 image -> sizes="16x16"
└── icon3.svg     # any SVG -> sizes="any"

Worth being clear about what the numbers do, because it trips people up: they only control the order the tags appear in. Numbered files sort lexically, and that's the whole story. The sizes attribute comes from the image's actual pixel dimensions, so icon1.png is 32x32 because the file is 32x32 pixels, not because it's called "1". SVG files, and any image whose dimensions Next.js can't read, get sizes="any".

Also worth knowing: lexical sort means icon10.png lands between icon1.png and icon2.png. If you're going past nine icons, you've probably got a different problem.

For explicit control over paths and attributes, use the metadata object instead.

Dynamic Icon Generation

Create an icon.tsx file for programmatic generation:

// app/icon.tsx
import { ImageResponse } from 'next/og'

export const size = {
  width: 32,
  height: 32,
}

export const contentType = 'image/png'

export default function Icon() {
  return new ImageResponse(
    (
      <div
        style={{
          fontSize: 24,
          background: '#000',
          width: '100%',
          height: '100%',
          display: 'flex',
          alignItems: 'center',
          justifyContent: 'center',
          color: '#fff',
          borderRadius: 8,
        }}
      >
        A
      </div>
    ),
    {
      ...size,
    }
  )
}

This generates icons dynamically - perfect for:

Apple Touch Icon

  • User-specific icons
  • A/B testing
  • Seasonal variations
  • Environment indicators

For iOS devices, create apple-icon.tsx:

// app/apple-icon.tsx
import { ImageResponse } from 'next/og'

export const size = {
  width: 180,
  height: 180,
}

export const contentType = 'image/png'

export default function AppleIcon() {
  return new ImageResponse(
    (
      <div
        style={{
          fontSize: 140,
          background: 'linear-gradient(to bottom right, #000, #333)',
          width: '100%',
          height: '100%',
          display: 'flex',
          alignItems: 'center',
          justifyContent: 'center',
          color: '#fff',
          borderRadius: 36,
        }}
      >
        A
      </div>
    ),
    {
      ...size,
    }
  )
}

Metadata API Approach

For fine-grained control, use the metadata API:

// app/layout.tsx
import type { Metadata } from 'next'

export const metadata: Metadata = {
  icons: {
    icon: [
      { url: '/icon-16x16.png', sizes: '16x16', type: 'image/png' },
      { url: '/icon-32x32.png', sizes: '32x32', type: 'image/png' },
    ],
    shortcut: '/favicon.ico',
    apple: [
      { url: '/apple-icon.png' },
      { url: '/apple-icon-180x180.png', sizes: '180x180', type: 'image/png' },
    ],
  },
  manifest: '/site.webmanifest',
}

The icons.other array is there if you need an unusual rel, but the one it used to be recommended for - Safari's mask-icon pinned tab SVG - is no longer worth shipping. Modern Safari uses your ordinary favicon for pinned tabs, and Apple's own guidance on it has been archived for years.

Route-Specific Favicons

Different favicons for different sections? Easy:

// app/admin/layout.tsx
export const metadata: Metadata = {
  icons: {
    icon: '/admin-icon.png',
  },
}

// app/shop/layout.tsx
export const metadata: Metadata = {
  icons: {
    icon: '/shop-icon.png',
  },
}

This creates distinct visual indicators for different app sections - particularly useful for admin areas or multi-tenant applications.

Dark Mode Support

Theme-aware icons go through the media property on the metadata object:

// app/layout.tsx
export const metadata: Metadata = {
  icons: {
    icon: [
      { url: '/icon-light.png', media: '(prefers-color-scheme: light)' },
      { url: '/icon-dark.png', media: '(prefers-color-scheme: dark)' },
    ],
  },
}

That emits a <link rel="icon" href="/icon-dark.png" media="(prefers-color-scheme: dark)">, and the browser picks. Both files live in public/.

There is no file-convention shortcut for this. You'll find tutorials claiming an icon-dark.png in app/ is "automatically detected" as the dark variant - it isn't, and it never has been. The only file conventions Next.js recognises are favicon, icon and apple-icon. The name icon-dark.png in the snippet above is just a filename we picked, and it means nothing to the framework. If you want the dark variant, you write the media property.

One honest caveat on the browser side: Chromium honours media on icon links, Safari doesn't switch favicons by theme at all, and Firefox is inconsistent. Design a default that reads on both, then treat the dark variant as the bonus.

Static vs Dynamic Generation

Static Icons (Default)

Place files in the app directory for static serving:

app/
├── favicon.ico
├── icon.png
└── apple-icon.png

Benefits:

Dynamic Icons

  • Zero runtime overhead
  • CDN-friendly
  • Predictable URLs
  • Best performance

Use .tsx files for runtime generation:

// app/blog/[slug]/icon.tsx
export default async function Icon({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params
  // Generate based on slug, headers, cookies, etc.
}

Note the Promise. Route params became promises in Next.js 15 and the synchronous fallback is gone in 16, so params gets awaited. Icon routes were the last to catch up, which is why older examples still type it as a plain object.

Use cases:

Web App Manifest

  • User avatars as favicons
  • Environment indicators (dev/staging/prod)
  • A/B testing different icons
  • Personalisation

For PWA support, add a static manifest:

// app/manifest.json
{
  "name": "My Next.js App",
  "short_name": "NextApp",
  "icons": [
    {
      "src": "/icon-192.png",
      "sizes": "192x192",
      "type": "image/png"
    },
    {
      "src": "/icon-512.png",
      "sizes": "512x512",
      "type": "image/png"
    }
  ],
  "theme_color": "#000000",
  "background_color": "#ffffff",
  "display": "standalone"
}

Or generate dynamically:

// app/manifest.ts
import { MetadataRoute } from 'next'

export default function manifest(): MetadataRoute.Manifest {
  return {
    name: 'My Next.js App',
    short_name: 'NextApp',
    icons: [
      {
        src: '/icon-192.png',
        sizes: '192x192',
        type: 'image/png',
      },
    ],
    theme_color: '#000000',
    background_color: '#ffffff',
    display: 'standalone',
  }
}

Performance Optimisation

Image Optimisation

Next.js automatically optimises static icons:

  • Compression
  • Format selection
  • Cache headers
  • CDN-ready URLs

Dynamic icons are a different story, and the levers are narrower than you'd hope. ImageResponse takes width, height, emoji, fonts, debug, and the usual HTTP passthrough (status, statusText, headers). That's the complete list.

There is no quality option and no runtime option - both turn up in a lot of copied snippets and neither exists. Output is always PNG. The way to keep a dynamic icon small is to keep the JSX simple: flat colours, one glyph, no gradients.

export default function Icon() {
  return new ImageResponse(
    // Your JSX here
    {
      width: 32,
      height: 32,
    }
  )
}

Preloading

You might reach for metadata.other to emit a preload hint. Don't - other only ever produces <meta name="..." content="..."> tags, so a key like 'link rel="preload"' gets you a malformed <meta> and no preload at all. Next.js lists <link rel="preload"> as unsupported by the Metadata API outright.

If you genuinely need one, use React's own API from a client component:

'use client'
import ReactDOM from 'react-dom'

export function PreloadIcon() {
  ReactDOM.preload('/icon.png', { as: 'image' })
  return null
}

For a 32x32 favicon this is almost never worth it. The browser requests the icon early anyway.

Common Patterns

Environment-Specific Icons

Show different icons per environment:

// app/icon.tsx
export default function Icon() {
  const isDev = process.env.NODE_ENV === 'development'

  return new ImageResponse(
    (
      <div style={{
        background: isDev ? '#f00' : '#000',
        // ... rest of styles
      }}>
        {isDev ? 'D' : 'P'}
      </div>
    )
  )
}

Animated Favicons

While not recommended, it's possible:

// app/icon.gif/route.ts
export async function GET() {
  // Generate or serve animated GIF
  // Note: Limited browser support
}

Migration from Pages Router

Moving from Pages Router? Key differences:

Pages Router (old):

// pages/_document.js
<link rel="icon" href="/favicon.ico" />

App Router (new):

// Just add files:
app/favicon.ico
app/icon.png

No more manual tags, no more document modifications.

Testing Your Favicons

Verify your implementation:

Creating Icons for Next.js

  1. 1Build locally: npm run build && npm run start
  2. 2Check generated HTML: View source for meta tags
  3. 3Test all routes: Ensure route-specific icons work
  4. 4Verify dark mode: Toggle system theme
  5. 5Mobile testing: Add to home screen on devices

Need perfectly sized icons? Try Unwrite's Favicon Generator. Upload your logo and download all required sizes for Next.js, including apple-touch-icon and multiple PNG sizes. Everything processes privately in your browser.

Best Practices

The Path Forward

  1. 1Use PNG over ICO: Better quality, smaller files
  2. 2Implement apple-icon: Critical for iOS users
  3. 3Consider SVG: Scalable and tiny
  4. 4Test dark mode: Growing number of users
  5. 5Keep it simple: Complex icons don't scale

Next.js's approach to favicons exemplifies the framework's philosophy: convention over configuration, with escape hatches when needed. Start with the simple file-based approach, then add complexity only if required.

The beauty lies in the simplicity - drop a file, get a favicon. Need more control? The APIs are there. It's modern web development at its finest.