Sprite and Glyph Pipelines for MapLibre Styles

A style references two asset bundles that have nothing to do with tiles: a sprite, which is one PNG of every icon plus a JSON index of where each one sits, and glyphs, which are the font ranges MapLibre fetches to draw labels. Both are ordinary HTTP resources, both are easy to forget in a deployment, and both fail by rendering nothing rather than by raising an error.

The three independent things a style fetchesA style fetches tiles from the tileset, a sprite sheet and its index for icons, and glyph ranges for label text, each from its own URL with its own deployment.THREE DEPENDENCIESStylestyle.jsonTilessource urlz/x/y or pmtilesSpritesprite.json + .png@2x variantsiconsGlyphs{fontstack}/{range}.pbflabel text
Only the first is produced by the tile build. The other two are separate artefacts with separate pipelines, and that separation is why they get forgotten.

Prerequisites

Requirement Why
SVG source icons The sprite is generated from them; PNG sources cannot be rescaled cleanly for @2x
A licensed font in TTF or OTF Glyph ranges are generated from it, and redistribution needs a licence that permits it
spreet or sprite-zero Sprite sheet generation
font-maker or fontnik PBF glyph range generation
A place to host both with CORS They are fetched cross-origin exactly like tiles

Core Concept: What Each Bundle Is

The sprite

A sprite is two files per pixel ratio: a PNG atlas containing every icon, and a JSON index giving each icon’s name, position, size and pixel ratio.

json
{
  "airport": { "width": 24, "height": 24, "x": 0,  "y": 0,  "pixelRatio": 1 },
  "rail":    { "width": 24, "height": 24, "x": 24, "y": 0,  "pixelRatio": 1 }
}

A style declares the base URL and MapLibre appends the rest:

json
{ "sprite": "https://tiles.example.com/v43/sprite" }

That produces four requests: sprite.json, sprite.png, and on a high-DPI screen [email protected] and [email protected]. Shipping only the 1× pair means icons are visibly soft on most modern devices; shipping only 2× means they are missing entirely on 1× displays.

The glyphs

Glyph ranges are protobuf files holding 256 characters’ worth of signed-distance-field outlines each. MapLibre fetches only the ranges a label actually needs, which is why a Latin-script map costs one or two requests and a CJK map costs many more.

json
{ "glyphs": "https://tiles.example.com/fonts/{fontstack}/{range}.pbf" }

{fontstack} is the comma-joined text-font array from the style layer, URL-encoded, and {range} is 0-255, 256-511 and so on. A style whose text-font says ["Noto Sans Regular"] fetches /fonts/Noto%20Sans%20Regular/0-255.pbf, and if that exact path does not exist, every label on the map silently disappears.

How a label turns into a glyph requestA style layer's text-font array becomes a fontstack path segment, the characters in the label determine which 256-codepoint range is needed, and the two combine into a PBF URL.GLYPH RANGEStext-fontstyle layerAn array of names, joined with commas — theexact strings the glyph directory must benamed after.fontstackURL segmentURL-encoded. Spaces become %20, which is whydirectory names on disk must match exactly.range256 codepointsOnly the ranges the visible labels need arefetched. Latin is 0-255; most maps neverneed more.PBFthe responseSigned-distance-field outlines. Missing heremeans no labels, with no console error.
The fontstack is matched as a string, not resolved as a font. A trailing space or a different capitalisation is a 404 and a map with no labels.

Step-by-Step Implementation

Step 1 — Collect the icons a style actually references

Generating a sprite from a directory produces icons nobody uses and misses ones the style names. Derive the list from the style instead:

bash
jq -r '
  [ .layers[]
    | (.layout // {})["icon-image"]
    | if type == "string" then . elif type == "array" then (.. | strings) else empty end
  ] | unique | .[]
' style/base.json | sort -u > icons.manifest

Expressions make this approximate — an icon-image built with concat cannot be enumerated statically — so the manifest is a lower bound and the verification step below is what closes the gap.

Step 2 — Generate the sprite

bash
spreet --unique --ratio 1 icons/ dist/v43/sprite
spreet --unique --ratio 2 icons/ dist/v43/sprite@2x

ls dist/v43/
# sprite.json  sprite.png  [email protected]  [email protected]

--unique deduplicates identical icons, which matters when a design system produces many near-duplicates.

Step 3 — Build the glyph ranges

bash
font-maker fonts/NotoSans-Regular.ttf dist/fonts/"Noto Sans Regular"
ls dist/fonts/"Noto Sans Regular"/ | head -3
# 0-255.pbf
# 256-511.pbf
# 512-767.pbf

The directory name must equal the text-font string exactly. This is the single most common cause of a label-free map, and it is invisible in the style, which looks entirely correct.

Step 4 — Publish with the right headers

Both bundles are versioned with the style that references them and get the same treatment as tiles:

bash
aws s3 sync dist/v43/ s3://tiles/v43/ \
  --cache-control "public, max-age=31536000, immutable" \
  --exclude "*" --include "sprite*"

aws s3 sync dist/fonts/ s3://tiles/fonts/ \
  --cache-control "public, max-age=31536000, immutable" \
  --content-type "application/x-protobuf"

Glyphs can be shared across style versions because a font range for a given font never changes; sprites should be versioned with the style, because adding an icon changes the atlas layout and therefore every index entry.

Step 5 — Verify every referenced name resolves

bash
BASE=https://tiles.example.com/v43

# Sprite index reachable, and every manifest icon present in it
curl -sf "$BASE/sprite.json" > /tmp/sprite.json || { echo "sprite.json 404"; exit 1; }
comm -23 <(sort icons.manifest) <(jq -r 'keys[]' /tmp/sprite.json | sort) \
  | sed 's/^/missing icon: /' | grep . && exit 1

# Every fontstack in the style has a 0-255 range
jq -r '[.layers[].layout["text-font"] // empty | join(",")] | unique | .[]' style/base.json \
| while read -r STACK; do
    URL="https://tiles.example.com/fonts/$(printf %s "$STACK" | jq -sRr @uri)/0-255.pbf"
    curl -sfI "$URL" > /dev/null || { echo "missing glyphs: $STACK"; exit 1; }
  done
echo "sprite and glyphs verified"
What to fetch before a style goes liveSix availability checks covering the sprite index, the atlas, the high-DPI variants, a glyph range per fontstack, CORS and content types.ASSET PREFLIGHTsprite.json and sprite.png both resolveand every icon the style names appears in the index[email protected] and [email protected] resolverequested on any high-DPI screen, which is most of themOne glyph range resolves per fontstack0-255 covers Latin; a missing range means no labels at allFontstack directory names match text-font exactlyincluding spaces and capitalisationCORS allows the map's origin on both bundlesthey are cross-origin fetches exactly like tilesGlyph responses are application/x-protobufa text content type can trip transforms that corrupt the body
Every one is a plain HTTP request. A style that validates against the schema and 404s on its sprite still renders a map with no icons.

Why These Assets Get Forgotten

Both bundles have a property that makes them uniquely easy to omit from a deployment: they are referenced by URL from a document that validates perfectly without them. A style with a broken sprite URL passes every schema check, every linter, and every structural test, because the style itself is not wrong — the thing it points at is missing.

That is compounded by three practical circumstances. They are produced by different tooling from the tiles, so a pipeline organised around the tile build has no natural place for them. They change on their own cadence, usually far slower than either tiles or styles, so a team can go months without touching them and forget they exist. And they work in development, because a local server usually serves them from the same origin with no CORS involved, so the first failure appears in staging.

The fix is not vigilance. It is making the dependency phase of style validation fetch the actual URLs on every deploy, which converts an invisible dependency into a build failure. That check is the six lines in step five, and it is the only thing standing between a correct style and a map with no icons.

Sizing and Delivery

Neither bundle is large by tile standards, and both sit on the critical path for the first rendered frame, so their delivery characteristics matter more than their size suggests.

A typical basemap sprite is 60–200 KB for the 1× PNG and roughly four times that at 2×, plus a few kilobytes of index. A Latin glyph range is 40–120 KB. Against a viewport of a dozen tiles at 100 KB each, that is a modest fraction of the bytes — but it is fetched before labels or icons can be drawn, so its latency is directly visible as a map that renders geometry first and then populates.

Three delivery habits follow. Serve both from the same origin as the tiles, so no extra DNS resolution or TLS handshake is on the path. Mark them immutable and version them, exactly as tiles are, since neither changes under its URL. And preload the sprite index if the map is the primary content of the page — it is a small, always-needed resource whose fetch can start before the style has finished parsing.

Optimization Knobs

Knob Conservative Aggressive Trade-off
Sprite scope Every icon in the design system Only icons the style references A narrow sheet is smaller but must be regenerated whenever the style adds an icon
Pixel ratios 1× and 2× Add 3× 3× is rarely distinguishable and doubles the atlas again
Glyph ranges published All ranges the font contains Only ranges the labels need Publishing everything is a few megabytes and removes a class of failure
Sprite versioning Version with the style Share across versions Sharing breaks the moment the atlas layout changes

Integration With Adjacent Pipeline Stages

Upstream, neither bundle depends on the tile build at all. That independence is why they can be versioned and deployed separately, and why they are omitted from tile-focused deployment checklists.

Downstream, the style is the only consumer, and it references both by URL. A style validation workflow that only checks the schema will pass a style whose sprite 404s — the dependency phase is what catches it.

Sideways, theme variants complicate sprites specifically: a dark theme usually needs re-tinted icons, which means either a second sprite sheet or icons designed to be recoloured through icon-color. The theme inheritance topic covers which of those keeps the two themes from drifting.

Troubleshooting

Labels vanish entirely

Symptom: Geometry renders, no text anywhere.

Cause: The glyph URL template resolves to a 404 — nearly always a fontstack name that does not match the directory on disk.

Fix: Fetch the exact URL MapLibre would request, including the URL encoding, and compare against the directory listing.

Icons render as blank squares

Symptom: Symbol layers place something, and it is empty.

Cause: The sprite index loaded but the named icon is not in it, so MapLibre reserves the space and draws nothing.

Fix: Compare the style’s icon-image values against the sprite index keys, as in step 5.

Icons are soft on retina screens

Symptom: Icons look blurry on a phone and crisp on an external monitor.

Cause: No @2x sprite, so MapLibre upscales the 1× atlas.

Fix: Generate and publish the @2x pair; it is one extra command.

In-Depth Guides

Generating Sprite Sheets for MapLibre Styles — deriving the icon list from the style, generating both pixel ratios, and keeping the atlas stable across builds.

Hosting Glyph Ranges for Custom Fonts — generating PBF ranges, naming the fontstack directories, and the licensing question redistribution raises.

Debugging Missing Icons and Fonts in MapLibre — reading the network panel to tell a 404 from a CORS failure from a name mismatch.

FAQ

Can I use a public glyph endpoint?

For development, yes, and several exist. For production it is a third-party dependency on the critical path of every label on your map, with no SLA — hosting your own is a few megabytes of static files.

Do sprites need to be versioned?

Yes, if the style is. Adding one icon reflows the atlas and changes every entry’s coordinates, so a cached old index against a new PNG draws the wrong icons — a distinctive and confusing failure.

Why are glyphs fetched in ranges at all?

So a map showing Latin labels does not download an entire CJK font. Only the 256-codepoint blocks actually needed are fetched, which for most maps is one or two requests.

Does icon-color work on any icon?

Only on SDF icons. A full-colour PNG icon cannot be recoloured at runtime, which is the deciding factor when a design must support light and dark themes from one sheet.

Next reading Debugging Missing Icons and Fonts in MapLibre Next reading Generating Sprite Sheets for MapLibre Styles Next reading Hosting Glyph Ranges for Custom Fonts