Next.jssharpfaviconNode.js

Turning an AI-Generated Logo Into a Real Favicon — a Next.js Favicon Build Log

July 2, 20261 min read

I'd unified the brand tone of a service called MatchDa around green, but the favicon that actually showed up in the browser tab was still the old "MD" text on a black background. The app is green, but the tab icon sticks out black — it feels off.

I happened to already have several logo candidates generated by AI (ChatGPT/Gemini), so I picked one and set out to turn it into a real favicon/app icon. You'd think "drop in one image and you're done," but actually doing it, there's surprisingly a lot to handle. Today I'm writing up that process.

Background — Next.js App Router's favicon conventions

Next.js (App Router) automatically converts conventionally named files under src/app/ into favicon meta tags. No need to write <link rel="icon"> in code.

FilePurpose
src/app/favicon.icoclassic favicon (covers legacy browsers)
src/app/icon.svg (or icon.png)modern-browser favicon
src/app/apple-icon.pngiOS home screen icon (usually 180×180)

In other words, all I need to do is produce these three files well and drop them in.

Step 1. Before picking a candidate, shrink it to 16px

The most important lesson first. A favicon ultimately gets viewed at 16×16, 32×32. A design that looks great at full size often falls apart once shrunk down. So when picking a candidate, don't guess by eye — actually shrink it and look.

On a Mac, sips (built in) shrinks it instantly.

sips -s format png -z 16 16 "logo.png" --out fav16.png
sips -s format png -z 32 32 "logo.png" --out fav32.png

I shrank 8 candidates this way and checked each one. The result was surprising — the flashy "network-connection M" had its thin lines completely mush together at 16px, while the mark of two people shaking hands forming an M simplified cleanly into "green tile + white M" once shrunk. The key turned out to be distinguishing detail that disappears when small from the silhouette that survives to the end.

Note: I backed up the existing favicon and logo before starting work. Icon swaps are the kind of thing you often want to undo later.

Step 2. Baking the SVG mark into PNG — sharp

Prepare the chosen mark as a clean SVG (vector, so infinite scaling is fine), then rasterize it into PNGs at the sizes you need. In a Node environment, sharp conveniently supports SVG input.

const sharp = require('sharp')
const fs = require('fs')

const svg = fs.readFileSync('icon.svg')
for (const size of [512, 180, 48, 32, 16]) {
  await sharp(svg).resize(size, size).png().toFile(`icon-${size}.png`)
}

For apple-icon.png, just use the 180px one from this batch directly.

Step 3. favicon.ico has to be hand-built

Here's where I hit a wall. Neither sharp nor sips can output .ico. ImageMagick would make this easy, but I didn't have it. So I hand-assembled the ICO file.

The ICO format turns out to be simpler than expected.

[ICONDIR, 6 bytes]  reserved(2)=0, type(2)=1, image count(2)=N
[ICONDIRENTRY, 16 bytes × N]  size/offset info for each image
[image data ...]  can embed PNG directly (supported by modern browsers)

The key is that PNG can be embedded directly as the payload. So you make the 16/32/48 PNGs and concatenate them.

function buildIco(entries) {           // entries: [{size, data(PNG buffer)}]
  const count = entries.length
  const header = Buffer.alloc(6)
  header.writeUInt16LE(0, 0)           // reserved
  header.writeUInt16LE(1, 2)           // type: icon
  header.writeUInt16LE(count, 4)       // count

  const dir = Buffer.alloc(16 * count)
  let offset = 6 + 16 * count
  entries.forEach((e, i) => {
    const b = i * 16
    dir.writeUInt8(e.size, b)          // width  (0 means 256)
    dir.writeUInt8(e.size, b + 1)      // height
    dir.writeUInt16LE(1, b + 4)        // planes
    dir.writeUInt16LE(32, b + 6)       // bpp
    dir.writeUInt32LE(e.data.length, b + 8)  // byte count
    dir.writeUInt32LE(offset, b + 12)        // offset
    offset += e.data.length
  })
  return Buffer.concat([header, dir, ...entries.map(e => e.data)])
}

After building it, I read the header back to verify it. (Always worth reading a binary like this back to confirm it's correct.)

const b = fs.readFileSync('favicon.ico')
console.log(b.readUInt16LE(2), b.readUInt16LE(4)) // 1(type), 3(count)

Step 4. Using the AI image as-is — trimming margins and rounding corners

When the request is "use the design from this exact image," you need to treat the PNG the AI produced as the source. But that image usually comes wrapped in white margins, with the tile's corners all over the place. sharp can clean this up.

// 1) auto-trim the outer white margin → down to the tile boundary
const trimmed = await sharp(SRC).trim({ threshold: 20 }).toBuffer()

// 2) resize to a square
const base = await sharp(trimmed).resize(512, 512, { fit: 'fill' }).png().toBuffer()

// 3) make the corners transparent with a rounded-rect mask (dest-in compositing)
const mask = Buffer.from(
  '<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512">' +
  '<rect width="512" height="512" rx="96" fill="#fff"/></svg>'
)
const rounded = await sharp(base)
  .composite([{ input: mask, blend: 'dest-in' }])   // keeps only where the mask is opaque
  .png().toBuffer()

blend: 'dest-in' is the key part. The mask (a white rounded rectangle) keeps only the opaque region, cutting the rest transparent, so the corners round out cleanly.

Step 5. Same mark for the in-app logo — using currentColor

Change only the tab icon, and it clashes with the logo elsewhere in the app (sidebar, header). I swapped the in-app logo to the same mark too. When building an inline SVG as a component, setting the color to currentColor lets it inherit color from CSS text-* classes, which makes reuse convenient.

export function HandshakeMark({ size = 18, ...p }) {
  return (
    <svg width={size} height={size} viewBox="0 0 512 512" fill="none" {...p}>
      <g fill="currentColor">{/* head, body, hand */}</g>
      <g stroke="currentColor" strokeWidth="18" strokeLinecap="round">{/* arm */}</g>
    </svg>
  )
}
// inside a green tile:  <HandshakeMark className="text-white" />

Step 6. When colors are subtly off — antialiasing-preserving recoloring

The last trap. The favicon used the AI image's green (#035647), but the app UI's brand color was #046C4E. Two subtly different greens coexisting is distracting. I wanted to change only the tile color to #046C4E, but simply "replacing green pixels with a different green" breaks the boundary (antialiasing) between the white mark and green tile into jagged steps.

So I linearly interpolated along the white↔green axis instead. Estimate how white each pixel is (t), and only shift the green endpoint to the new green.

const G1 = [4, 108, 78]   // target #046C4E
const TMIN = 3            // original tile's minimum channel (#035647's R) → t=0 baseline

for (let i = 0; i < data.length; i += 4) {
  if (data[i + 3] === 0) continue                 // keep transparency
  // whiteness t: 1 if the pixel is white (the mark), 0 if it's tile green
  let t = (Math.min(data[i], data[i+1], data[i+2]) - TMIN) / (255 - TMIN)
  t = Math.max(0, Math.min(1, t))
  // interpolate between white(255) and G1 by t → the mark stays white, only the tile shifts to new green
  data[i]   = Math.round(t * 255 + (1 - t) * G1[0])
  data[i+1] = Math.round(t * 255 + (1 - t) * G1[1])
  data[i+2] = Math.round(t * 255 + (1 - t) * G1[2])
}

The trick is using t = min(R,G,B) as "whiteness." White has all three channels high, so min is high too (t≈1); dark green has a low min (t≈0). Semi-transparent boundary pixels land somewhere in between, so the transition stays smooth. The resulting tile color came out exactly #046c4e.

Troubleshooting

  • Can't produce a .ico → sharp/sips don't support ICO. Hand-assemble the ICO with PNG embedded as the payload.
  • The AI image has white margins → trim with sharp().trim(), and clean up the corners with a rounded mask (dest-in).
  • The tab icon isn't updating → browser favicon caching is extremely stubborn. If it's still the same after deploying, hard-refresh/clear cache, or check in an incognito window. It's likely not the code's fault.
  • The color is subtly off → instead of a simple swap, preserve AA with a linear remap along the white↔brand-color axis.

Summary

The whole flow, from AI logo to real favicon, at a glance.

  1. Pick candidates after shrinking them to 16/32px (the silhouette that survives small is the real deciding factor)
  2. Next.js auto-recognizes favicon.ico, icon.svg, apple-icon.png under src/app/
  3. SVG→PNG with sharp, baked at each size
  4. Hand-build favicon.ico (header + entries + PNG payload)
  5. When using an AI image, clean it up with trim + a rounded mask
  6. Unify colors with an AA-preserving linear remap
  7. If it's still not changing, nine times out of ten it's the browser cache

I thought a favicon was "drop in one image and you're done," but it turned out to be fairly intricate work — shrink-testing, a binary format, color correction. Thanks to it, the tab, home screen, and in-app logo all now line up neatly under the same green mark.

PM

backtodev

A 40-something PM returns to code. Learning, failing, and growing.

Turning an AI-Generated Logo Into a Real Favicon — a Next.js Favicon Build Log | backtodev