Taking 242 Client Names Out of an Image
242 client names used to live inside one image. Now they are indexed text, readable by screen readers, with a total computed from the data.
TL;DR
Client names were locked in a JPEG, invisible to screen readers and Google. The fix moved 242 names into a typed TypeScript module grouped by sector and rendered them as real searchable HTML with Next.js. The image stays for visual proof under WCAG's logo exception, while live text keeps the list accessible, indexed, and always in sync.
I tried to copy one client name from the portfolio page, and the cursor only managed to highlight an empty blue box over a giant JPEG. No text to select, nothing to copy. At that moment it clicked: 242 company names that should work as social proof had been decoration all along.
My first guess was to stuff every name into the alt attribute of that image. Bad idea. A 242-name alt text is a screen reader nightmare: one giant unbroken block read aloud with no sensible pause. Alt text exists to describe an image, not to hide an article inside it.
Search engines are equally blind here. Google indexes images through standard img elements with a src attribute, and it says so directly: images rendered through CSS are not indexed [1]. Names trapped inside pixels were never content to begin with. Both audiences, assistive tech and crawlers, got nothing.
From Pixels to a TypeScript Module
The fix was separating visual presentation from raw data. The source was the company profile PDF: 242 names grouped into 21 sector groups inside one shared TypeScript module named client-names.ts. Each entry has a sector label and a names array. Names are written verbatim from the document, including the inconsistent periods and commas, because this list is evidence, not a pretty display.
interface SectorGroup {
sector: string;
names: string[];
}
export const clientGroups: SectorGroup[] = [
{
sector: "Energi",
names: ["PT. Contoh Energi", "PT. Contoh Power Indonesia"]
},
{
sector: "Perbankan",
names: ["PT. Contoh Bank Sejahtera", "Bank Contoh Digital"]
}
];
export const totalClients = clientGroups.reduce(
(acc, group) => acc + group.names.length,
0
);
The total, 242 companies, is now computed with reduce from the data itself. The number on the page can no longer go stale, because it is born from the same array. The page runs on the Next.js App Router, so this component is a Server Component by default: data stays close to its source and less JavaScript ships to the browser [3].
Proving it takes ten seconds. Open view-source on the client page and search for one company name. It shows up as real HTML now. Back when the names only lived inside the image, that search always came up empty.
Why the Logo Wall Stays
If the text is complete, why keep the image at all? WCAG 2.2 has a criterion for exactly this, Success Criterion 1.4.5 Images of Text at level AA: when the same visual presentation is achievable with real text, use real text. It carries exceptions, and one of them is essential presentation such as logotypes and brand names [2]. The same document allows the combination I picked: an image of text accompanied by the same information as real text counts as conforming [2]. Their official example is an event poster with the same text placed next to it.
So the wall image stays on top as visual proof. The brand presents as a brand. What changed is the part that talks to crawlers and screen readers: plain text now, not pixels.
The Trade-off I Accepted
Content as code means every new client requires a commit and a deploy. Typo risk during the one-time transcription from the PDF is real too, and no SEO gate catches a misspelled company name.
Both are true, and I picked this route anyway.
Client list changes are rare events. In exchange the data is queryable, filterable per sector, and accessibility friendly, which beats the occasional annoying update. The displayed total can never drift from the actual data again, and every name has a place that can be searched, read, and quoted.
Sources: