DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Our favicon is 1,322 bytes, our touch icon is rasterised from it at build time, and we serve no favicon.ico at all

Munchable is a Next.js site with a bitten chocolate chip cookie for a logo. There are three places a browser or a crawler asks for that logo, and the three answers are built differently on purpose. You can check every number in this post yourself with curl.

One drawing

$ curl -sI https://munchable.app/icon.svg | grep -i content-
content-type: image/svg+xml
content-length: 1322
Enter fullscreen mode Exit fullscreen mode

1,322 bytes, a 32-unit viewBox, four large chips in a single tone. It is a deliberate simplification of the master artwork, which has seven chips in two browns, because at 16 pixels seven chips in two shades is mud. That is optical work, not a port, and it is the only reason two drawings of the same cookie are allowed to exist in the repo.

It is referenced the ordinary way, and Next appends its own content hash:

<link rel="icon" href="/icon.svg?icon.17r._5ai05ov1.svg" sizes="any" type="image/svg+xml">
Enter fullscreen mode Exit fullscreen mode

There is no .ico

$ curl -s -o /dev/null -w '%{http_code}\n' https://munchable.app/favicon.ico
404
Enter fullscreen mode Exit fullscreen mode

favicon.ico is a container format holding several bitmap sizes, and the thing it exists for is a browser that cannot read rel="icon" with an SVG. Every browser we serve can. Shipping one would mean another artefact to regenerate every time the mark changes, to be subtly wrong the one time somebody forgets. A 404 on a path nobody fetches costs nothing.

iOS will not take the SVG

Home screen icons are the exception. iOS ignores SVG for apple-touch-icon, so that one has to be a PNG, and a PNG of a logo is exactly the sort of file that drifts: somebody exports it once at 180 pixels, the mark gets tweaked a year later, and the home screen keeps the old cookie forever.

So it is generated, from the SVG itself, by a route file:

export const size = { width: 180, height: 180 };
export const contentType = 'image/png';

export default function AppleIcon() {
  const cookie = readFileSync(join(process.cwd(), 'app', 'icon.svg')).toString('base64');

  return new ImageResponse(
    (
      <div style={{ /* centred, background: '#FFF8EC' */ }}>
        <img src={`data:image/svg+xml;base64,${cookie}`} width={140} height={140} alt="" />
      </div>
    ),
    size,
  );
}
Enter fullscreen mode Exit fullscreen mode

The useful trick is the data URI. Satori, the renderer behind next/og, will rasterise an SVG handed to it that way, with its masks intact. So the touch icon is not an approximation of the favicon, it is the favicon, on the cream background, with 20 points of breathing room on each side because iOS rounds the corners and a full bleed mark loses its rim.

$ curl -sI 'https://munchable.app/apple-icon' | grep -i content-
content-type: image/png
content-length: 5764
Enter fullscreen mode Exit fullscreen mode

5,764 bytes, produced from 1,322 bytes of source. Nothing to keep in sync.

The share card used to work the same way, and now does not

The 1200x630 card every shared link renders was, for a while, 141 lines of JSX in app/opengraph-image.tsx: the site's warm gradient, two faint radial blobs mostly off canvas, four crumbs hugging the edges, the cookie twice (46 pixels beside the wordmark, 340 pixels as the thing your eye lands on), and three font files read off disk and handed to ImageResponse.

Its own comment made the case for itself: generated rather than checked in as a binary, so the palette lives in one place and the card cannot drift away from it.

It is now a 122,886-byte PNG checked into the repository, and I think that was the right trade.

A share card is not a page. It changes when the brand changes, which is roughly never, and in between it must produce byte-identical output on every build. A build-time render is a strange way to guarantee that: it is three TTFs, a renderer with its own flexbox subset, and a layout you cannot see without building. The guarantee it offers (this card cannot drift from the palette) is real, but it is a guarantee against a change you would make deliberately, once, and would notice.

The touch icon is the opposite case and that is why it is still generated: it is the same artwork at a different resolution, it must follow the source exactly, and nobody would ever notice if it stopped.

$ curl -sI https://munchable.app/opengraph-image.png | grep -i content-length
content-length: 122886
Enter fullscreen mode Exit fullscreen mode

The metadata rule that bites

Here is the part that is actually a Next.js gotcha, and left alone it would give this site exactly one share card.

The opengraph-image file convention fills in openGraph.images for you. It only does that for a segment that does not set openGraph itself. Every content page on this site sets its own openGraph, because each one needs its own og:url, title and description. So for all of them the convention does not apply: the homepage would have a card, and the other 425 URLs in the sitemap would have none.

The fix is to name the image in the shared metadata helper rather than rely on the convention:

const OG_IMAGE = {
  url: '/opengraph-image.png',
  width: 1200,
  height: 630,
  alt: `The ${SITE_NAME} cookie logo beside the words "${SITE_TAGLINE}".`,
};
Enter fullscreen mode Exit fullscreen mode

The alt line is a second trap sitting inside the first. The file convention also supplies alt text, from opengraph-image.alt.txt or an exported alt. Set openGraph.images yourself and that goes too, so og:image:alt is emitted nowhere unless you restate it. There is now one copy of that sentence in the metadata helper and one in opengraph-image.alt.txt, which is the kind of duplication that is tolerable precisely because the alt text of a share card does not change.

You can confirm it lands everywhere. Any page, any route shape:

$ curl -s https://munchable.app/recipes/gentle-rice-congee | grep -o '<meta property="og:image[^>]*>'
Enter fullscreen mode Exit fullscreen mode

That is the congee recipe; the same check works on the reflux condition page and on any of the 373 generated pages under the answers index.

One last coupling, because the root is a dynamic segment

The answer pages live at the site root: app/[question]/page.tsx serves /is-apple-low-fodmap and 372 others. A dynamic segment at the root means every top-level asset path is in the same namespace as the content, so the content module keeps a reserved set, checked at module load, of paths a generated slug may never take. apple-icon is in it. So is opengraph-image.png.

Which is why renaming that route from /opengraph-image to /opengraph-image.png was a two-file change: the metadata helper, and the reserved list that stops a page from being shadowed by an image.

No slug could collide today, since all of them begin is- or does-. The list is there so that adding a new phrasing in a year cannot quietly delete a page.

Top comments (0)