# Never Ship a Broken Image

**NEVER** · **ID:** `content-impeccable-broken-image` · **Category:** content
**Source:** [impeccable](https://impeccable.style/)
**Interactive version:** https://ui-guides-agent-rules.netlify.app/principles/content-impeccable-broken-image

> NEVER: Ship an `<img>` with an empty, missing, or placeholder `src` — it renders the browser's broken-image box. The value is rarely a literal; it is `user.avatar` and the CMS returned null, so `<img src={undefined} />` compiles, type-checks, passes review, and breaks for every record missing the field. Two failure modes, two guards: MISSING URL — guard the RENDER, do not emit an `<img>` at all, ship a real fallback (initials avatar, skeleton, neutral box); URL THAT 404s — guard the NETWORK with `onError` and swap in the same fallback. Reserve identical dimensions for both, or the swap adds a layout shift on top of the bug you just fixed.

Guard the render and guard the network — an <img> with no resolvable src ships as a broken-image box

The detector catches three shapes: `<img src="">` (also `src=" "` and `src="#"`), an `<img>` with no `src` attribute at all, and a src that is a placeholder rather than a real asset. It is the most trivial rule in impeccable's registry and it reaches production constantly, because the value is usually not a literal — it is `user.avatar` or `post.cover`, and the CMS returned null. React then omits the attribute entirely, so `<img src={undefined} />` compiles, type-checks, passes review, and renders the browser's broken-image chrome to every user whose record happens to be missing that field. There are two distinct failure modes and each needs its own guard. Missing URL: guard the RENDER — if there is no src, do not emit an `<img>`; ship a real fallback instead (an initials avatar, a skeleton, a neutral placeholder box) so the layout is intact and the meaning still reads. URL that fails to load: guard the NETWORK — a remote src can be present and still 404, so attach `onError` and swap in the same fallback the moment the browser gives up. Reserve identical dimensions for image and fallback, or the swap introduces a layout shift on top of the broken image you just fixed.

## Bad — do not do this

`content-impeccable-broken-image-bad`

```tsx
/**
 * Bad: `<img>` tags whose src never resolves.
 *
 * Both boxes below are real, not mock-ups — the browser is rendering its own
 * broken-image chrome.
 *
 * 1. `user.avatar` came back `undefined` from the CMS, so React omits the
 *    attribute entirely and ships an `<img>` with no src.
 * 2. `post.cover` is a path that 404s. Nobody checked, because it resolved in
 *    staging.
 *
 * The third variant — a literal `<img src="" />` — fails the same way.
 */

// What the CMS actually returned
const user: { name: string; avatar?: string } = { name: 'Ada Lovelace' };
const post = { title: 'Shipping on Fridays', cover: '/uploads/cover-9f3c-missing.png' };

export function ImpeccableBrokenImageBad() {
  return (
    <div className="w-full space-y-4">
      <div className="flex items-center gap-3 rounded-lg border border-border bg-card p-3">
        {/* user.avatar is undefined → React omits src → <img> with no src */}
        <img
          src={user.avatar}
          alt={user.name}
          width={40}
          height={40}
          className="size-10 rounded-full border border-border"
        />
        <div>
          <p className="text-sm text-foreground">{user.name}</p>
          <p className="text-xs text-muted-foreground">
            <code>&lt;img src={'{user.avatar}'} /&gt;</code> — avatar is undefined
          </p>
        </div>
      </div>

      <div className="rounded-lg border border-border bg-card p-3">
        {/* A real 404 → the browser draws its broken-image box */}
        <img
          src={post.cover}
          alt={post.title}
          width={160}
          height={90}
          className="h-[90px] w-40 rounded-md border border-border object-cover"
        />
        <p className="mt-2 text-sm text-foreground">{post.title}</p>
        <p className="text-xs text-muted-foreground">
          <code>{post.cover}</code> — 404s in production
        </p>
      </div>

      <p className="text-xs text-error">
        An <code>&lt;img&gt;</code> with an empty, missing, or placeholder src ships as a
        broken-image box. It is the most trivial bug in this corpus and it reaches production
        constantly, because a null image URL from a CMS is invisible until it renders.
      </p>
    </div>
  );
}
```

## Good — do this

`content-impeccable-broken-image-good`

```tsx
import { useState } from 'react';

/**
 * Good: never render an `<img>` you cannot vouch for.
 *
 * 1. Guard the render — no src, no `<img>`. Ship a real fallback instead (an
 *    initials avatar), so the layout is intact and the identity still reads.
 * 2. Guard the network — a src can be present and still 404, so `onError` swaps
 *    in the fallback the moment the browser gives up. The cover below points at
 *    a URL that does not exist; you are seeing the fallback, not a broken box.
 */

const user: { name: string; avatar?: string } = { name: 'Ada Lovelace' };
const post = { title: 'Shipping on Fridays', cover: '/uploads/cover-9f3c-missing.png' };

const initials = (name: string) =>
  name
    .split(' ')
    .map((part) => part[0])
    .join('')
    .slice(0, 2);

export function ImpeccableBrokenImageGood() {
  const [coverFailed, setCoverFailed] = useState(false);

  return (
    <div className="w-full space-y-4">
      <div className="flex items-center gap-3 rounded-lg border border-border bg-card p-3">
        {/* Guarded render: only emit <img> when there is something to load */}
        {user.avatar ? (
          <img
            src={user.avatar}
            alt={user.name}
            width={40}
            height={40}
            className="size-10 rounded-full border border-border"
          />
        ) : (
          <span
            aria-hidden="true"
            className="flex size-10 items-center justify-center rounded-full border border-border bg-muted text-xs font-semibold text-muted-foreground"
          >
            {initials(user.name)}
          </span>
        )}
        <div>
          <p className="text-sm text-foreground">{user.name}</p>
          <p className="text-xs text-muted-foreground">
            No avatar URL, so no <code>&lt;img&gt;</code> — an initials fallback instead.
          </p>
        </div>
      </div>

      <div className="rounded-lg border border-border bg-card p-3">
        {/* Guarded network: onError swaps in the fallback when the URL 404s */}
        {coverFailed ? (
          <div className="flex h-[90px] w-40 items-center justify-center rounded-md border border-border bg-muted text-xs text-muted-foreground">
            No cover image
          </div>
        ) : (
          <img
            src={post.cover}
            alt={post.title}
            width={160}
            height={90}
            onError={() => setCoverFailed(true)}
            className="h-[90px] w-40 rounded-md border border-border object-cover"
          />
        )}
        <p className="mt-2 text-sm text-foreground">{post.title}</p>
        <p className="text-xs text-muted-foreground">
          <code>{post.cover}</code> 404s — <code>onError</code> already swapped it out.
        </p>
      </div>

      <p className="text-xs text-success">
        Two guards, because there are two failure modes: a missing URL (guard the render) and a URL
        that fails to load (guard with <code>onError</code>). Both reserve the same box, so nothing
        shifts when the fallback takes over.
      </p>
    </div>
  );
}
```

## References

- [impeccable.style](https://impeccable.style/)
- [pbakaus/impeccable](https://github.com/pbakaus/impeccable)
- [MDN — <img>: The Image Embed element](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/img)
