A Map You Can Display Is Not a Map You Can Ship
Overture's official build-a-map example walks you from a DuckDB query to a rendered map in about ten minutes, and it ends in a handoff: the last instruction points at a hosted demo for complete styling. Rendering is where the tutorial stops. It is also roughly where a production map starts making demands the tutorial never opens.
Here is the claim, stated so a skeptic can push at it: between that rendered map and a shipped one sit four commitments the demo never names. The style references source layers whose names you chose at tile time. The map carries an attribution line the data licenses expect. The user can do something on hover. And the tile asset survives the next release, which matters because Overture ships monthly and the schema moves with it. None of that makes the demo wrong; it makes the demo the first half.
Everything below runs through one extract. The box is the Lake Tahoe basin: -121.0 to -119.6 longitude, 38.7 to 39.5 latitude. Counted against Overture release 2026-09-23.1 with DuckDB on 2026-10-11, it holds 28,166 places and 228,935 buildings, and the transportation theme accounts for 106,242 segments in the same box. The map ships its places layer first, because points are the cheapest way to watch each of the four commitments get made or missed.
The extract decides what the popup can ever say
The demo's DuckDB step reads as data selection, and the first time you run it, that is all it is. The moment a popup appears, the column list becomes interface design. Places rows in the current schema (v2.0.0) carry names, confidence, websites, socials, a taxonomy struct and a bbox column beside the geometry. The hover will use three of those and no more.
COPY (
SELECT
geometry,
names.primary AS name,
taxonomy.primary AS category,
confidence
FROM read_parquet(
's3://overturemaps-us-west-2/release/2026-09-23.1/theme=places/type=place/*.parquet',
hive_partitioning = 1
)
WHERE bbox.xmin >= -121.0 AND bbox.xmax <= -119.6
AND bbox.ymin >= 38.7 AND bbox.ymax <= 39.5
) TO 'tahoe-places.geojson';
Two decisions hide in that SELECT. Dropping attributes you will never style keeps them out of the tiles entirely, and the same script will eventually run against regions far denser than this box, where the difference compounds. Writing the field list by hand is also how the next problem surfaces early: taxonomy.primary is the v2.0.0 replacement for the categories field that earlier releases carried, and that rename reappears in this post as a breakage.
The tile output is one .pmtiles file, and a browser reads tiles straight out of it over HTTP range requests, with no tile server anywhere in the deployment. That single property is why this pipeline fits the static hosting most small products already run. It deserves a sentence because the demo never stops to say it.
Whatever that SELECT drops is quietly gone from the interaction vocabulary.
Two files have to agree on a layer name, and nothing checks them
tippecanoe names the vector layer as part of writing the archive. Name it explicitly rather than letting the filename decide:
tippecanoe -o tahoe-places.pmtiles -l tahoe_places -Z8 -z14 tahoe-places.geojson
The style then has to repeat that exact string as its source-layer:
{
"sources": {
"overture-tahoe": {
"type": "vector",
"url": "pmtiles://https://tiles.example.com/tahoe-places.pmtiles",
"attribution": "© OpenStreetMap contributors, Overture Maps Foundation"
}
},
"layers": [
{
"id": "tahoe-places-dot",
"type": "circle",
"source": "overture-tahoe",
"source-layer": "tahoe_places"
}
]
}
Miss the string and the layer you wrote it for draws nothing. The map still renders, the browser console stays near-quiet, and the bug reads as bad data until someone opens the tile contents in an inspector. We stopped hand-copying the -l value into the style and let the tile script emit the style fragment instead, because a name checked by two files and two humans is a name that drifts.
A source layer that two files can disagree on, with no checker between them, is a bug you find in front of a user.
Hover is the extract, wearing markup
Interactions bind to layer ids, so the popup attaches to tahoe-places-dot and reads the properties the extract kept:
import { Protocol } from "pmtiles";
maplibregl.addProtocol("pmtiles", new Protocol());
const popup = new maplibregl.Popup({ closeButton: false });
map.on("mouseenter", "tahoe-places-dot", (e) => {
const row = e.features[0].properties;
popup
.setLngLat(e.lngLat)
.setHTML(`<strong>${row.name ?? "Unnamed place"}</strong><br>${row.category ?? ""}`)
.addTo(map);
});
map.on("mouseleave", "tahoe-places-dot", () => popup.remove());
The fallbacks in that template are not decoration. In the Tahoe extract, 896 of the 28,166 places carry a NULL taxonomy.primary in release 2026-09-23.1, so a template that trusts the category field renders eight rows in a hundred as blank lines. The attribute list you cut at extract time is the whole vocabulary the popup can draw on; that is the first commitment being honored at step one.
The popup is the extract, wearing markup.
tippecanoe covers one city, and Overture ships its own tiler
For a custom single-city extract like this one, tippecanoe stays a reasonable tool: GeoJSON in, PMTiles out, complete control over the layer name and zoom range, no new infrastructure. It is the path we would keep for anything shaped like the Tahoe box.
The calculus changes when the target is the whole release. Overture's GitHub org carries overture-tiles, a Java tool that produces PMTiles directly from official releases, with terraform-aws-overture-tiles alongside it for standing up hosted deployments. Drawing the line we would actually use: extract and reshape one city, keep tippecanoe; re-tile an entire release on a schedule without owning the code, read the official tool's README before writing your own. Both paths end in the same .pmtiles contract, which is why the MapLibre style above would only change its source URL.
The tool changes; the tile contract does not.
The same tile file reaches more than one runtime
MapLibre's JavaScript build is only one of the places this asset pays off. MapLibre Native, the iOS and Android SDKs, speaks the same source model, and PMTiles archives work there too, so one tile file can sit behind the browser map and the mobile app without a second pipeline. Where an older pinned SDK lacks direct PMTiles reading, the workaround is small and boring: bundle the archive with the app and point the platform's adapter at it, or convert to MBTiles for that one target.
We run MapLibre in production client work for offline ski-trail and elevation maps, and the pleasant surprise of that setup is administrative: with one tile asset serving every runtime, the recurring argument is not about pipelines but about device budgets, which is where the real trade lives. The trail side of that stack is its own post, building a trail graph from OpenStreetMap.
One tile asset, many runtimes; the trade you feel is the device budget.
What the demo never measures
The first unmeasured cost is tile generation on dense themes. Counting the buildings in the Tahoe box alone took 37.1 seconds of DuckDB time against release 2026-09-23.1, and the building schema carries roof color and facade material beside height. Multiply by a whole region and the archive gets heavy through attributes the style never reads, so filter the fields before tiling and clip the extract to the area you actually serve. A buildings archive is the project where this lesson finds you.
The second is zoom discipline. tippecanoe's density rules decide which features survive each zoom, and a map can be locally correct at every level while feeling emptier at the level your users actually live at. The workaround is unglamorous and it works: pin a short list of coordinates a user would recognize, then screenshot those coordinates through every zoom the product actually ships. What changes between zoom 10 and zoom 12 is the user's opinion of the map.
The third is the release cadence. Overture ships monthly and the schema moves with it, and the categories to taxonomy rename in schema v2.0.0 (release 2026-09-23.1) is a live example: a style filter written against the old field name fails without an error, because the field is simply absent. The fix is procedural. The re-tile script belongs in version control, runs against a named release ID, and gets read against the release notes before the tiles reach users.
The fourth is a budget the demo has no way to see: battery. A mobile map pays on the device, where heavy layers and long tile loads show up as battery drain and slow cold starts on the phones least able to absorb them. Keep the layer count and the maxZoom deliberately small on the native side, and run the real check on hardware you would describe as old.
None of the four costs appears in a demo, and all four appear in a release.
The re-tile script is the deliverable
If one decision survives this post, make it this: the artifact worth versioning is not today's .pmtiles file but the script that regenerates it from a named release ID, with its field list, its zoom range and its attribution string checked in beside it. The layer-name commitment, the attribution commitment, the hover vocabulary and the update plan all live in that one file where they can be reviewed and re-run. Display is a demo state. Shipping is a pipeline that exists whether you remember it or not.
Thanks for reading this far. If this sounds like the problem you're wrestling with, tell us about it. A principal reads every message.
Start a conversation