All Posts
CampbellSoft Studios

Why Your SVG Renders Blank: HTML Entities Are Not XML

We shipped five hero images. Two rendered. Three were blank — and the browser didn't say a word. Here's the bug, the fix, and the four-line script we wrote to make sure it can't happen again.

Engineering SVG Bugs Pre-Ship

We rebuilt this site’s blog with custom hero images last week. Five posts, five hand-written SVGs at 1200×600, each one inlined into the markdown frontmatter as image: /images/blog/<name>.svg. Built locally, deployed to Netlify, hard-refreshed.

Two of them rendered. Three of them showed nothing — not a broken image icon, not a console error, just an empty 2:1 box where the hero should have been.

This post is about the bug, the fix, and the four-line script we wrote so it can’t happen to us again.

What we ruled out first

The reflex when an <img> shows blank is to check the obvious things in order:

  • 404? No. All five SVGs returned HTTP 200.
  • Wrong MIME type? No. All five returned Content-Type: image/svg+xml.
  • CSS collapsing the box? Possible — we’d just refactored away from aspect-[2/1] object-cover, which had its own quirks. But the box dimensions checked out in DevTools for both the working and broken ones.
  • Browser cache? No, hard refresh and incognito both repeated the failure.

So the file existed, the server served it correctly, and the browser laid out a 2:1 box for it. The <img> element was just refusing to draw the SVG inside it.

The thing that was different

The two working SVGs used only ASCII text. The three broken ones each contained the same character: &middot;.

Specifically, things like:

<text>per year &middot; under $1/mo</text>
<text>~ 15 minutes &middot; before merge &middot; every time</text>

That’s the bug. &middot; is an HTML named entity — and SVG isn’t HTML. SVG is XML.

XML knows exactly five named entities

This is one of those things that’s obvious once you know it and invisible until then. The XML 1.0 spec defines exactly five named character references:

  • &amp;&
  • &lt;<
  • &gt;>
  • &quot;"
  • &apos;'

That’s it. The full list. Nothing else.

HTML, on the other hand, defines a thousand-plus named entities. &middot;, &nbsp;, &copy;, &rarr;, &mdash;, &ndash;, &hellip;, &trade; — all valid HTML, all invisible to XML.

When a browser loads an SVG via <img src="file.svg">, it parses that file as strict XML, not as HTML. The first time it hits an unknown named entity, the parser errors out and stops processing the file. The error doesn’t bubble to the console. The <img> just renders nothing.

That’s the worst class of bug: a silent declarative-format failure. There’s no exception, no log, no warning. The build succeeds, the server serves a 200, and the user sees a blank.

The fix

Two paths, both safe:

Option 1 — Use the literal Unicode character. This is what we did. &middot;·. SVGs are UTF-8 by default, the literal character round-trips fine, and there’s no parser to confuse.

Option 2 — Use a numeric character reference. &#183; (decimal) or &#x00B7; (hex) for the middle dot. Numeric refs ARE valid XML — only named entities outside the canonical five are not. If you have a reason to keep the entity-style escape (legacy tooling, search-and-replaceability), this is the version that works.

Defending against the regression

Bugs that ship invisibly come back invisibly. Code review didn’t catch this one, and code review can’t reliably catch it next time either — it’s not a code-correctness question, it’s a file-format question, and reviewers don’t open every SVG to grep for ampersands.

So we wrote a build-time linter:

// scripts/lint-svgs.mjs (excerpt)
const VALID = new Set(['amp', 'lt', 'gt', 'quot', 'apos']);
const ENTITY_RE = /&([a-zA-Z][a-zA-Z0-9]*);/g;

// for every .svg in public/ and src/:
//   match every named entity
//   if it isn't in VALID, fail with file:line:col + the offending entity

Wired to prebuild in package.json:

"scripts": {
  "build": "astro build",
  "prebuild": "node scripts/lint-svgs.mjs"
}

Now npm run build runs the linter first. If anyone — including future-us in six months — drops &copy; into a hero SVG, the build fails before dist/ even gets touched. The CI pipeline can’t ship the bad file because the build can’t produce one.

The script is forty lines of plain Node, no dependencies, no install step. It paid for itself the day we wrote it.

The wider point

Silent failures in declarative formats — SVG, JSON, YAML, TOML, CSS — share a property that makes them especially nasty: there’s no runtime to throw an exception. The file is either parseable or it isn’t, and “isn’t” usually means “renders blank” or “applies no rule” instead of “logs an error you can react to.”

Code review can catch the kinds of mistakes humans make in code. It cannot catch the kinds of mistakes humans make in data. The defense for the second category isn’t more careful reviewers — it’s a thirty-second build-time linter that’s mechanically incapable of getting tired or distracted.

If you’ve shipped any non-trivial declarative content (SVGs, JSON config, YAML manifests, RSS/Atom feeds, OpenAPI specs, structured-data blocks), there’s almost certainly a class of “silent invalid” bug waiting in there. The cure isn’t expensive. It’s a prebuild step you write once and stop thinking about.

The five-entity rule is a tiny thing. The lesson is bigger: lint your data the way you lint your code, especially when the failure mode is silence.