Skip to content
Consultation

Why My Blog List Renders Stack Icons on the Server

Adityo Guni Waluyo

Runtime Iconify requests made my blog list icons appear late. I replaced them with server-rendered SVG and fallback icon sets.

The cards on my blog list appeared first. The tech-stack icons arrived afterward, one card at a time. That was not an animation I had added. DevTools showed requests to api.iconify.design for nearly every card, with a delay of more than a second on an ordinary connection.

My first guess was wrong: I suspected a font or layout shift. The source was <Icon> from @iconify/react. It is a client component. In Next.js, its SVG is not rendered on the server; it waits until mount to avoid hydration errors, then fetches icon data from the Iconify API.

For a small mark in a list, I did not want the page to depend on a third-party request. I removed that runtime path.

SVG built at build time, not page-view time

The commit replaces runtime fetching with @iconify/json and @iconify/utils. The first package contains icon-set data in IconifyJSON format. The second provides utilities for reading icon data and building SVG body and attributes.

TechIconTile is now server-rendered. It processes an icon with iconToSVG, then places the body inside an inline SVG. The browser receives finished markup instead of instructions to fetch another resource.

import { iconToSVG, replaceIDs } from "@iconify/utils";

const { body, attributes } = iconToSVG(iconData, { height: "1em" });
const svg = `<svg ${attrToString(attributes)}>${replaceIDs(body)}</svg>`;

replaceIDs is not decoration. Some SVGs use id values for gradients, masks, or clip paths. When two instances share an id in one document, a reference can point at the wrong element. The replaceIDs documentation describes this function as a way to make embedded SVG ids unique.

The trade-off is clear: icon-set data makes the build dependency larger. I measured the JSON files in this checkout: simple-icons is 4.6 MB, logos is 7.2 MB, devicon-plain is 2.6 MB, and fa6-brands is 497 KB. Those are source-file sizes in node_modules, not an automatic claim about what reaches the browser. The mapping in tech-icons.ts selects the icons used, and the result is rendered on the server.

I chose the build cost. For a static blog, a runtime CDN request just to draw a tiny image is the wrong kind of optimization.

Tag mapping needs fallbacks and word-boundary tests

Blog tags do not always have a match in one icon set. Simple Icons is the primary source, but the Simple Icons policy allows brands to be removed at a brand owner request or under its own criteria. The mapping therefore has fallbacks in devicon, logos, arcticons, selfhst, and fa6-brands.

The installed simple-icons version had 3,730 icons when I checked it in August 2026. systemd, haproxy, and yandex were absent from that set, so I sourced them elsewhere. A wrong icon name is intentionally loud: the required() helper throws during the build. A red build beats an empty tile discovered after deployment.

Another small problem appeared in ranking. I wanted icons mentioned in an article title to move to the primary position. My first matcher used includes(), so the go tag could match a title containing google. That is not ranking; it is a substring accident.

const esc = alias.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const re = new RegExp(`(?<![a-z0-9])${esc}(?![a-z0-9])`, "i");

The regular expression checks the characters before and after the alias. go must stand alone, so it cannot match inside google. This small test matters because the alias list keeps growing; one character can change the primary icon across the entire list.

The blog cards now receive inline SVG in the first response. The build does more work, and local dependencies are heavier. Readers do not pay for another round trip, never see an empty tile for a moment, and keep their icons when Iconify is slow.

Related articles