Skip to main content
CryptoFlex// chris johnson
ShippingSubscribe
§ 01 / The Blog · Quick Tips

Replacing the Default Next.js Favicon: Two Bugs Hiding in Plain Sight

My tab strip still showed the stock Next.js triangle because nobody had replaced the scaffold favicon.ico, and my web manifest pointed at a logo that wasn't even square. Fixing both took a Pillow crop, per-frame tuning at 16 pixels, and a maskable safe zone.

Chris Johnson··6 min read

My tab strip still showed the stock Next.js triangle after 773 commits. That wasn't a regression from last week, either. The icon had been wrong since the Create Next App scaffold commit in February, and in all that time, nobody, including me, had looked at it twice.

I grabbed a screenshot of the tab strip and asked Claude Code to fix it:

"Ok I want to change the icon for the link render in chrome tabs or on iphone. It shows the default vercel Icon but I want it to be the cryptoflex icon."

The Two Triangles in My Tab Strip#

Chrome tab strip showing two tabs, each with a plain white triangle inside a black circle where a site icon should be

Two tabs, "Cry" and "Ana," each truncated to a title and a generic white triangle in a black circle. It's the placeholder Next.js ships with every new project, and it had survived an editorial redesign and an analytics rebuild without anyone noticing.

That sent us looking for the actual file instead of guessing at a fix.

The File Nobody Touched#

ls src/app turned up favicon.ico next to layout.tsx, manifest.ts, robots.ts, and sitemap.ts. Running file on it confirmed the default: an MS Windows icon resource, four frames at 16x16 and 32x32, 25,931 bytes.

The real tell was git log --oneline -- src/app/favicon.ico: one commit, 28c6cbe Initial commit from Create Next App, never touched again across 773 commits of everything else changing around it.

Why was the scaffold icon still the one being served? layout.tsx answered it: its metadata object has no icons field, so nothing was overriding the file-based default.

App Router icon conventions

A file literally named favicon.ico, icon.*, or apple-icon.* inside app/ gets picked up automatically and rendered as a <link> tag in the page head. You don't need metadata.icons at all unless you want to override that convention.

The Wordmark Wasn't a Square Either#

Claude Code found a second bug while it was in there. src/app/manifest.ts declared one PWA icon entry pointing at /CFLogo.png, sized 512x512, purpose any. The actual file, public/CFLogo.png, is 993x314, a wide wordmark with the shield on the left and "CryptoFlex" spelled out on the right.

Nothing in the build had caught it.

Manifests don't validate dimensions

Neither the Next.js build nor tsc --noEmit checks that a declared icon size matches the file behind it. A manifest happily accepts "sizes": "512x512" next to a file that isn't square at all. The only way to catch it is to open the file yourself, with something like sips -g pixelWidth -g pixelHeight or file.

Building the Shield From the Wordmark#

The CryptoFlex wordmark on a dark background with an amber crop box drawn around just the shield mark

We needed a square icon, and the wordmark only had a shield-shaped piece worth keeping.

A tight alpha bounding box came back almost the entire left panel, since faint sparkle specks around the shield counted as content too. Claude Code settled on a hand-picked 280x280 crop instead, centered on the shield.

The shield itself is mostly transparent strokes: teal outlines, circuit traces, a center chip. Left alone, it would just sit directly on whatever color the browser's tab strip happened to be. So we composited each tile onto a dark rounded square in #0f0f12 instead, the same value the manifest already used for theme_color and the site background, so the shield reads the same on light and dark tab strips.

python
BG = (15, 15, 18, 255)  # matches manifest theme_color #0f0f12

def boost(img, alpha_gain, bright):
    r, g, b, a = img.split()
    a = a.point(lambda v: min(255, int(v * alpha_gain)))
    rgb = ImageEnhance.Brightness(Image.merge("RGB", (r, g, b))).enhance(bright)
    return Image.merge("RGBA", (*rgb.split(), a))

def tile(size, radius_frac, pad_frac, alpha_gain=1.0, bright=1.0):
    big = 1024
    t = Image.new("RGBA", (big, big), (0, 0, 0, 0))
    mask = Image.new("L", (big, big), 0)
    ImageDraw.Draw(mask).rounded_rectangle((0, 0, big - 1, big - 1), radius=int(big * radius_frac), fill=255)
    t.paste(Image.new("RGBA", (big, big), BG), (0, 0), mask)
    inner = int(big * (1 - 2 * pad_frac))
    s = boost(shield, alpha_gain, bright).resize((inner, inner), Image.LANCZOS)
    t.alpha_composite(s, (int(big * pad_frac), int(big * pad_frac)))
    return t.resize((size, size), Image.LANCZOS)

Everything renders at 1024px first and downscales once with Image.LANCZOS, so the smaller sizes come from one clean source instead of stacked resize artifacts.

No ImageMagick? Pillow works fine

This Mac has sips and rsvg-convert but neither magick nor convert. Python's Pillow (11.3.0 here) handled the crop, the alpha math, and the ICO export without needing anything else installed.

The 16 Pixel Problem#

The tile function above has alpha_gain and bright arguments for a reason we didn't expect until we previewed the actual sizes.

Why did the shield hold up at 64px and 32px but fall apart at 16px? Upscaled with nearest-neighbor so we could actually see the pixels, the smallest frame was dim and muddy. The thin strokes were just averaging into the dark background and disappearing.

Side by side 16 pixel favicon tiles on light and dark strips, the left set dim and muddy, the right set boosted and legible

The fix was per-frame tuning instead of one setting for every size. Both the 16px and 32px frames got an alpha gain of 1.8 and a brightness boost of 1.35, with padding cut from 6% to 3% so the shield had more room to read at that scale.

The 48px frame needed a much lighter touch, 1.4 and 1.2, since it wasn't struggling in the first place. We checked both against a light gray strip and a dark strip before trusting either one.

That meant building the ICO from three separately tuned tiles rather than downscaling one master image three times:

python
ico16 = tile(16, 0.22, 0.03, 1.8, 1.35)
ico32 = tile(32, 0.22, 0.03, 1.8, 1.35)
ico48 = tile(48, 0.22, 0.04, 1.4, 1.2)
ico48.save("src/app/favicon.ico", format="ICO",
           sizes=[(16, 16), (32, 32), (48, 48)],
           append_images=[ico16, ico32])

Three favicon frames extracted from the shipped ICO file at 16, 32, and 48 pixels, each upscaled for clarity

Claude Code reopened the saved file and pulled each frame back out to confirm the sizes and the tuning actually stuck: (16, 16), (32, 32), (48, 48), each one legible.

The new favicon.ico came out to 9,800 bytes, well under the 25,931-byte scaffold default.

Opaque for iOS, Maskable for Android#

The favicon wasn't the only icon this site needed. apple-icon.png had to be 180x180, RGB with no alpha channel, square corners, 8% padding.

iOS applies its own rounded-corner mask to touch icons, so the source image ships with square corners and full-bleed content instead of trying to round it yourself.

iOS turns transparency black

iOS renders any transparency underneath its mask as solid black. Flatten apple-icon.png to RGB with no alpha channel before shipping it.

The manifest needed two more shapes beyond its old single entry. icon-192.png and icon-512.png use purpose any, a rounded tile at 6% padding, matching the favicon's look.

icon-maskable-512.png uses purpose maskable: full-bleed, square, 16% padding so the shield sits inside the safe zone Android actually crops to, a centered circle whose radius is 40% of the icon's width.

Two square PWA icons side by side, one rounded for standard use and one full bleed for maskable use, with an amber circle marking the safe zone

manifest.ts went from one icon entry to three: 192 any, 512 any, 512 maskable, with theme_color staying at #0f0f12 since that part was already right.

What curl Actually Showed#

I had Claude Code run npx next dev -p 3917, curl the homepage, and grep the head for the icon tags:

html
<link rel="manifest" href="/manifest.webmanifest"/>
<link rel="icon" href="/favicon.ico?favicon.3iu3qa3e1n2-c.ico" sizes="48x48" type="image/x-icon"/>
<link rel="apple-touch-icon" href="/apple-icon.png?apple-icon.26qbfd5e2znh1.png" sizes="180x180" type="image/png"/>

All six routes came back 200 with the right content types, the md5 of the served favicon matched the file on disk, and tsc --noEmit stayed clean.

That query string on the href isn't a bug: Next.js appends a hash so browsers that honor it bust cache on redeploy. middleware.ts already excluded static assets and the manifest, so nothing there needed to change either.

Shipping it doesn't mean everyone sees it instantly, though.

Still seeing the triangle?

Chrome caches favicons hard enough that an already-open tab can keep showing the old one until you restart the browser or visit /favicon.ico directly. On iPhone, delete the existing home-screen bookmark and re-add it to pick up the new icon.

An iMessage link preview mostly won't change either: those pull from the page's og:image instead, served here by /api/og, not the favicon.

The same git log --oneline -- src/app/favicon.ico that started this now prints two lines: 5caaff5 fix: replace default Next.js favicon with CryptoFlex shield icon above 28c6cbe Initial commit from Create Next App.

Related Posts

Dark editorial cover for Security Review: Round Two, part 9 of the Security Engineering series. Two chips read Security Engineering and Part 9, Biannual Site Review, above a kicker reading 6 Agents, 1 Rule, Nothing Ships Without Consensus. Below, two side-by-side terminal-style code panels contrast a safe frontmatter fence against a dangerous one: the left, green-bordered panel labelled Data, Renders Fine shows a plain --- fence with an ordinary title and date, captioned Plain frontmatter fence, always just data; the right, red-bordered panel labelled Code, Would Execute shows a ---js fence with its payload marked omitted, captioned One labelled fence, now rejected before parsing, every push. Beneath the panels, the title reads Security Review: Round Two, with Round Two in teal. A mono deck line beneath the title reads The worst finding was hiding in the content folder, caught by consensus, not a scanner. A row of six chips names the review team: AppSec Pentester (Captain, Audit Lens), Red Teamer (Exploit Lens), Sec. Researcher (Novel Bugs), Threat Intel (OSINT and CVEs), Sec. Engineer (Fix Design), and an amber-accented Advocate chip (Cost vs. Risk Veto). An italic closing line reads Five lenses find it, the advocate weighs what it's worth to fix. The footer shows the Cryptoflex LLC, From the Workshop brand strip and the text Fixed and Deployed, Round Three in 6 Months.

A team of six agents, five security specialists plus an advocate representing the site owner, ran a consensus-driven security review of cryptoflexllc.com. The standout finding: an ordinary-looking blog draft that could have been parsed as executable code instead of data.

Chris Johnson··16 min read
A dark teal cover graphic titled Ledgerly, Part 3, One Design System, Eight Agents, with the kicker looked like stock shadcn, now wears the same design system as this site. On the left, a glowing teal card labelled one design system record shows the path _ds/cryptoflex-design-system/ above a mono file tree of tokens/fonts.css, tokens/colors.css, tokens/typography.css, tokens/spacing.css, tokens/elevation.css, tokens/motion.css, tokens/base.css, styles.css, and _ds_bundle.js, a chip reading linked by path, 2nd project, and an italic note that it was built for cryptoflexllc.com in April, linked rather than copied by Ledgerly's 783-line prototype in August, with the main session keeping the theme, shared components, sidebar, and dashboard. A teal bus line leaves the record, runs along the top under the labels one session, eight concurrent agents and one route directory plus its spec per agent, and drops two trunks with eight arrowed stubs into eight lane cards in two columns. Lane 01 db-engineer, schema and analytics, migrations 0004 and 0005, four new aggregates, an amber pragma bug tag, caught mid-build with two regression tests. Lane 02 transactions, transactions.spec.ts, six tests and zero lines touched. Lane 03 budgets, spec changed with the product, not the paint. Lane 04 chat, zero lines touched. Lane 05 cash flow and lane 06 recurrings, both tagged new route, covered by nav.spec.ts growing from six entries to eight. Lane 07 reports, spec changed with the product. Lane 08 import plus onboarding, zero lines touched. Along the bottom, a rail reading 46 Playwright tests, all green, with 18 outlined green squares for tests in the five spec files never touched and 28 filled squares for the five that changed with the product, and the line "One design system, eight agents at once. Forty-six tests stayed green."

Ledgerly's UI still worked but looked like stock shadcn, so the whole thing went through Claude Design and came back wearing CryptoFlex's Cyber Editorial system, the same one from April's site rebuild. Eight parallel agents rebuilt every screen against real backend constraints while 46 Playwright tests stayed green, one of them written to prove a SQLite migration bug on purpose.

Chris Johnson··27 min read
A dark teal cover graphic for Ledgerly, Part 2, A Chat That Can Query Its Own Database, with the kicker ten read-only tools, twelve turns, the same SQLite file. The dominant element is a terminal-style chat transcript panel titled ledgerly, chat, turn 3 of 12 with an SSE live indicator. In it the user asks "list my recurring expenses" and the assistant turn shows two green tool-call chips in order, get_recurring_merchants then query_transactions, labelled tools/call over stdio, followed by a serif answer whose second sentence is highlighted in amber, "One thing worth flagging, your recurring mortgage payment is marked excluded in your ledger." An amber callout beneath it reads 1,054 rows, Nobody Asked, wrongly excluded since the first import, restored, 3,056 excluded rows audited with a dry run first. A narrower right rail titled Every Turn, On The Wire, 5 hops, 1 file, stacks five numbered hops joined by dashed arrows, Browser, Next.js /api/chat spawning the CLI as an argv array with no shell, claude CLI v2.1.228 headless with --max-turns 12 picking tools, a hand-rolled MCP stdio server of about 250 lines with no SDK exposing 10 tools, and SQLite opened with query_only on, read only. Below the rail a flags block reads Confined By Flags, $0.5715 to $0.0438 per turn, with --tools empty, --setting-sources empty, and --strict-mcp-config. A bottom line reads "Ten tools. Twelve turns. It found 1,054 transactions nobody asked about."

Part one's chat answered from two months of category totals stuffed into a single-turn prompt, and it could not run one query against the database sitting right there. Part two replaces that with a hand-rolled MCP server, ten read-only tools, and multi-turn tool calling confined by CLI flags a de-risking spike worked out against the real claude CLI. Three review passes ran before the commit, and one confirmed finding was a tool-name mismatch that every unit test passed and only a captured NDJSON fixture caught. The same work also turned up a data problem that had nothing to do with any of it: 1,054 real transactions wrongly marked excluded since the original import in part one.

Chris Johnson··20 min read

Comments

Subscribers only — enter your subscriber email to comment

Reaction:
Loading comments...

Navigation

Blog Posts

↑↓ navigate↵ openesc close