Skip to content

[Placeholder] Was the Feature: Typed Content Cards

Adityo Guni Waluyo

Company cards changed shape three times in a week, but the typed content contract held: optional fields plus truthiness guards make empty data hide itself.

TL;DR

Building a bilingual Next.js company profile, I kept adding card fields and filling gaps with placeholder strings. Turns out optional TypeScript properties plus truthiness guards meant absent data simply never rendered, no helper logic needed. Typed content files beat a headless CMS at this scale, surviving three redesigns with zero component changes.

I opened the About section on staging and there were seven cards in a row, each with a neat chip on top. Three of them said [PLACEHOLDER] right in the middle of the meta row. Not broken layout, not missing data, just that literal string sitting there in production font like it belonged.

We were building a bilingual marketing site on Next.js App Router for a client company-profile site. The brief was simple: show the seven group companies as cards. The content lived in two typed files, id and en, that both implemented the same interface. The component just mapped over the array.

And that array would not sit still.

The cards kept growing

First version was minimal, name plus description. Then someone asked for a sector chip, so we added sector to the content type and rendered a little pill. A few days later it was "can we add founded and location?" So two more fields appeared in the cards, each with its own row and icon. I didn't have verified copy for half of them, so I filled the gaps with [PLACEHOLDER] directly in the content files. Intentionally ugly, so reviewers couldn't miss it.

Then feedback flipped. The meta rows made the cards too busy, and the whole block got pulled out of About into its own section entirely. Three shape changes in under a week, content files edited, component moved, but the interface itself barely changed. That was the first hint I was looking at the wrong thing.

I assumed I'd need to handle the half-filled state with actual logic. A visibility flag, a showPlaceholder prop, some if-ladder that knew which fields were safe to render. I was already sketching a helper that would check hasWebsite, hasLocation, you get it. Felt responsible.

Turned out I didn't need any of it.

The contract did the hiding

The interface was the contract, and the contract made hiding the default. In TypeScript, object types define data shapes and a question mark marks a property optional, which only means that if the property is set it must have the specific type [6]. So website?: string doesn't mean "anything goes", it means "either a string or not there at all". And if I typo websiet in an object literal, excess property checking makes it a compile error, not silent acceptance [6].

On the render side, a React component function accepts a single props object, props are read-only snapshots in time and every render receives a new version [7]. My card component wasn't mutating anything, it just received that snapshot and decided what to show.

The decision was one line. JavaScript if statements coerce their conditions to booleans and the empty string, null and undefined all coerce to false [8]. So an optional field plus a truthiness guard means absent or empty data simply does not render. I didn't need a flag. The language did it. And when I did need to check explicitly, a check like == null covers both null and potentially undefined at once [8].

That's why those [PLACEHOLDER] strings mattered. They were truthy. They rendered. Anything I left as undefined or "" vanished on its own. Unverified data wasn't hidden behind logic, it was absent by design.

interface CompanyCard {
  name: string;
  description: string;
  website?: string;
}

function Card({ data }: { data: CompanyCard }) {
  return (
    <div>
      <h3>{data.name}</h3>
      <p>{data.description}</p>
      {data.website != null && data.website !== "" && (
        <a href={data.website}>Visit site</a>
      )}
    </div>
  );
}

If website is undefined or empty, that last line is just false and React skips it. No helper needed.

A content file, not a CMS

After the stripping and the move, the pattern settled and stopped moving. Two content modules, same interface, single source of truth. The section component maps over the array and renders whatever passes the guard. When we added the parent company as the seventh card, it was an edit to those two content files and zero changes to the component. No prop threading, no conditional branch to update.

I have a firm take here: for a small bilingual site like this, a typed content module beats pulling in a headless CMS. I know the CMS pitch, live editing, non-dev updates, all real benefits at scale. But we had maybe thirty strings per language, fixed layout, tight deadline. A CMS would have added auth, webhooks, preview builds, and another place for typos to hide without a compiler yelling. With typed files I get autocomplete, excess property errors, and grep.

Now when copy is still pending I leave the field out entirely. No placeholder string, no comment to remember to remove. The card just renders without that row, and the diff that finally adds the data is one line in a content file. I kept expecting I'd need more abstraction for this, but the simplest contract held through three redesigns. I'm keeping it that way until it actually hurts.

Related articles