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.
💫 Server-side static map rendering for Typescript, with a React-shaped API
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:
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);
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.

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:
<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:

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.
