stillmap: static maps without a browser, a key, or a third party

Why we built a TypeScript library that renders a map to SVG or PNG in plain Node.js®, with no headless browser, no native map library, and no API key.

André LorethAndré Loreth··8 min read
Share
Central Hamburg from the Elbe to St. Georg, rendered by stillmap in a warm off-white palette with an orange marker.

We wanted a map on a contact page. One address, one image, no interaction. The usual answer is to point an <img> at a provider’s static maps endpoint, and that is where we started.

What you get with it is a per-visitor request to a company that is not you. In Germany that alone drags a decorative image into consent territory, and it arrives with an API key to rotate and a bill metered on views. The awkward part is that the meter counts the wrong thing. We had one map. It was going to be identical for every visitor for as long as the office stayed at that address, and we would be paying per impression to have it redrawn.

Serving it ourselves ran into the part nobody had made easy. OpenStreetMap data is free to use commercially and several providers publish vector tiles built from it, so the tiles were never the problem. Rendering them was: the options were to drive a headless browser or to bind a native map library, and both are a strange amount of machinery to keep alive for a static image of one street corner.

So we built stillmap. Describe a map as JSX, get back an SVG string or a PNG buffer. It does not drive a browser, bind a native map library, or ask you for an API key.

stillmap

💫 Server-side static map rendering for Typescript, with a React-shaped API

TypeScript20 forksApache-2.0

What it looks like

A map is a component tree. You give <Map> a source, a viewport and a size, then declare the layers you want drawn:

render a map
import {
Attribution,
Font,
Map,
Pin,
Road,
Water,
renderMap,
} from "@stillmap/react";
import { openFreeMap } from "@stillmap/sources";
import { writeFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
const HAMBURG = [9.9937, 53.5511] as const;
const INTER = fileURLToPath(new URL("./Inter.ttf", import.meta.url));
const { png } = await renderMap(
<Map
source={openFreeMap()}
center={HAMBURG}
zoom={13}
width={1200}
height={300}
background="#F5F5F3"
>
<Font family="Inter" file={INTER} />
<Water fill="#E1E4E7" />
<Road classes={["secondary", "tertiary"]} stroke="#FFFFFF" width={2} />
<Road classes={["motorway", "trunk"]} stroke="#FCFBF9" width={3.2} />
<Pin position={HAMBURG} fill="#9DB59D" />
<Attribution />
</Map>,
{ format: "png", scale: 2 },
);
await writeFile("map.png", png);
A wide, pale map banner of Hamburg showing only water and two road classes, with a green pin and attribution in the corner.
What that snippet produces. It looks sparse because it is: the tree declares water and two road classes, so that is all that gets drawn.

There is no base style being loaded and then overridden here, which is the part people trip over. A layer you do not declare is not drawn at all, and a layer you declare without a colour paints black. Delete the <Water> line from that snippet and the Elbe disappears; the colours are load-bearing rather than a theme applied over something that already looked like a map.

Three square maps of the same part of Hamburg side by side, each adding more layers: water only, then the road network, then ground, buildings, labels and a marker.
The same viewport with one more layer group declared each time: water, then roads, then ground, buildings, labels and a marker. Nothing appears until you ask for it.

For a banner that wants a few colours and no clutter, starting from nothing is less work than subtracting from a full basemap. When you just want a map that looks finished, it is the wrong default, and @stillmap/styles covers that case with ready-made styles you drop in and recolour through props.

JSX instead of style JSON

The usual way to describe a rendered map is a MapLibre style document: a JSON array of layer objects, each with a source layer name, a filter expression, and a paint block. It works, and it is miserable to write by hand. Nothing tells you that transportation is a real layer name and transport is not, and the filter syntax is a small language you learn once a year.

Layers here are typed components instead:

classes are typed
<Road classes={["motorway", "trunk"]} stroke="#FCFBF9" width={3.2} />

classes autocompletes to the road classes that actually exist, and <Building> will not accept a road class. Document order is paint order, so the tree reads back to front the way the image is drawn.

A style is not a special file format either, it is a component that returns layer elements. So a theme is a parameter rather than a second file:

The same map of Hamburg twice, once on a warm off-white ground and once on a dark charcoal ground, with an orange marker in both.
One component, one viewport, two palettes passed as props. The tree that produced these two images is identical.

One style, any tile provider

Layer names do not overlap between providers. Roads are transportation in OpenMapTiles, streets in Shortbread, and roads in Protomaps, and the property names and value vocabularies differ underneath that too. Writing a style against one provider normally means rewriting it if you ever switch.

So a source is tiles plus a schema plus attribution, and the schema maps a canonical kind such as road or water onto whatever that provider actually publishes. <Road> names the canonical kind, and the schema resolves it to the layer that provider happens to call it. That is what lets one style work across sources.

The seam exists, but most of the adapters do not yet. What ships today is the OpenMapTiles schema and an OpenFreeMap source. MapTiler and Stadia serve the same schema, so switching to those is a source change and no style edits, but Shortbread and Protomaps adapters do not exist yet.

What it refuses to do

Attribution is structural. <Attribution /> takes its text from the resolved source, has no disabled prop, and omitting the element places attribution at a default corner rather than removing it. It is drawn into the raster, so it survives the file being copied, embedded, or re-hosted. The failure mode of an HTML overlay is that the image eventually gets separated from its markup, and then a licence condition quietly stops being met. There is no API for turning it off and there should not be one.

A PNG that would draw text with no font declared throws. It does not warn and carry on. The rasteriser loads no system fonts, and every scene carries attribution, so that render would silently drop the one element that may not be omitted. Same reasoning for rejecting .woff and .woff2 up front: resvg loads a web font without raising anything and then draws no text at all. A map that renders without its labels looks finished and is not, which makes it worse than a map that fails.

Failures carry codes, not prose. A tile that fails every retry is a TILE_FETCH_FAILED warning and renders as a gap, because one missing tile in a banner is usually better than a 500. When a partial map is worse than no map, strict: true promotes every warning to a throw, which is what you want in CI and golden tests. Codes are stable across releases and messages are not, so they are meant for humans reading logs rather than for catch blocks matching strings.

Those refusals matter more the less anyone is looking at the result. A map rendered in CI, in a nightly batch, or by an agent writing its own calling code has nobody to notice that the labels went missing, and a library that hands back a plausible image in that situation is worse than one that stops.

Status

stillmap is Apache-2.0 and published on npm. The API is not stable, and the whole thing is new enough that the interesting bug reports have not arrived yet.

Appendix: how it actually works

The pipeline. Project the viewport to Web Mercator pixel bounds, work out which tiles cover it, fetch them, decode the Mapbox Vector Tile protobuf, build the paths, place labels with collision detection, serialize to SVG. PNG is that SVG handed to resvg afterwards, which is why the raster path is strictly the SVG path plus one step.

React is not rendering anything. renderMap walks the element tree itself. Every component is built from one factory and returns a plain declaration object rather than markup, and <Map> returns nothing at all; the walker reads its props directly. React is here for JSX, types, and composition. Nothing in the core package imports it.

The only native dependency is optional. SVG output needs nothing beyond Node.js®. @resvg/resvg-js is an optional peer, pulled in only if you want a PNG buffer at the end.

Every map on this page, the header image included, was rendered with stillmap against OpenFreeMap tiles.

This article was written with the support of artificial intelligence. Given what we do here, it would have been strange not to.

Share