Building offline-first trail maps from OpenStreetMap data
OpenStreetMap names trail segments, not hikes. Turning one into the other took a second data model, a routing engine, and a build pipeline that has to be reproducible.
RidgeSync plans multi-day backpacking trips, and a trip planner that needs signal fails at exactly the moment it matters. So the map has to be on the phone before you leave, which means the trail data has to be something I build rather than something I fetch. The hard part was not the offline packaging. It was discovering that OpenStreetMap does not contain the thing I thought I was downloading.
OSM names segments, not hikes
The naive pipeline, which is the one I built first, is: query Overpass for ways tagged highway=path with a name, emit one hike per named way, done. It produces a database full of plausible-looking trails, and it is wrong in a way that is hard to see until you try to walk one.
The example that made it obvious is a way called Hell Brook Cut Off. It is a real, correctly tagged, correctly named OSM feature. It is also a 0.4 mile connector that begins halfway up a mountain, hundreds of vertical feet from the nearest road, and ends on another trail. Published as a hike, it offers the user a route nobody walks, starting where nobody can park.
Two models instead of one
The fix is to stop pretending they are the same object, which is also how AllTrails and HikerAtlas split it. There are two datasets with two jobs.
| Segments | Routes | |
|---|---|---|
| What it is | Every trail way from OSM, as geometry | A curated hike: start, sequence, finish |
| Where it comes from | Overpass extract, deterministic | Modelled on top of segments |
| What it powers | Vector tiles and custom routing | What the user browses and plans |
| Needs a trailhead | No | Yes, and it must have parking |
Segments are the substrate: complete, mechanical, no editorial judgement, rebuilt from OSM on every pipeline run. Routes are the product: fewer, deliberately chosen, each one starting somewhere a person can actually begin. A connector like Hell Brook Cut Off is present in segments, so routing can use it and the map can draw it, and absent from routes, so nobody is offered it as a trip.
Seven datasets, all build steps
Everything the planner needs is generated the same way: a deterministic build step that takes an Overpass extract and emits a sharded artefact. Seven of them, covering hikes, campsites, waterfalls, viewpoints, water sources, trail segments and cell coverage.
Deterministic is the operative word, and it is a property you have to defend. An Overpass query against the live API returns different bytes on different days, because the map changes, which means a build that queries live is not reproducible and a bug you saw yesterday may not exist today. So the queries are cached, the cache is keyed by query text, and a build either uses the cache or explicitly refreshes it.
# Refresh the extract deliberately, as its own step.
npm run osm:fetch -- --region=vt-nh --layer=trails
# Then build from the cache. Repeatable byte-for-byte.
npm run dataset:build -- --layer=trails
npm run dataset:build -- --layer=water
npm run tiles:pack -- --out=packs/vt-nh.pmtilesRouting that follows trail
A straight line between two points on a trail map is a lie about distance and a worse lie about elevation. Routing runs through Valhalla against the segment database, so a planned leg follows real trail, and the distance and climb it reports are the ones you will actually walk.
Elevation is its own preprocessing problem. The profile under each day comes from a rebuilt elevation dataset rather than from sampling a terrain tile at render time, because the second approach gives you a different answer at a different zoom, and a climb that changes when you pinch is not information.
Why PMTiles
An offline map pack wants to be one file. The usual alternative is a directory of tiles, which on a phone means tens of thousands of small files, a filesystem that hates you, and a download that cannot be resumed cleanly.
PMTiles is a single archive with an index at a known offset, so a tile read is a range request into a local file. One download, one file to delete when the user clears a trip, and the same archive works served over HTTP for the web planner. The tile pack, the trip document, the elevation data and the day's stops are all tracked by an offline manifest that lives in the shared kernel, so both clients agree on what “downloaded” means.
type OfflineManifest = {
tripId: string;
/** Everything that must be local before the trip is usable with no signal. */
required: Array<
| { kind: "tiles"; pack: string; bytes: number }
| { kind: "elevation"; bbox: [number, number, number, number] }
| { kind: "trip"; revision: number }
>;
completedAt: string | null;
};What I would do differently
- Build the segment/route split first. I wrote the naive one-hike-per-way pipeline, shipped it to myself, and only found the problem by planning a trip on it. The split is not a refinement, it is the model, and retrofitting it meant rebuilding every dataset.
- Cache Overpass from day one. I spent an evening chasing a dataset diff that was the map changing under me rather than my code changing.
- Treat cell coverage as a first-class layer earlier. It turns out to be the layer that decides where the offline packs need to be complete, and I added it last.
The general lesson is narrower than "OSM is messy", which is both true and useless. It is that OSM is a faithful model of physical features, and a product built on it usually needs a second model of human intent sitting on top. The mapping between them is the actual work, and it is not in the data.
References
- OpenStreetMap
The source data for every trail, campsite and water source in RidgeSync.
- Valhalla routing engine
Trail-snapped routing, so a planned line follows real trail.
- PMTiles
Single-file tile archives, which is what makes an offline map pack one download.
- Overpass API
The query interface the dataset build steps extract from.
What this is about
The projects
On film