I spent a summer building TrainRouter — a free, no-signup interactive map of 767 of the world's notable train routes across 118 countries, from the Glacier Express and the Trans-Siberian to the new night trains stitching Europe back together. Every line carries its distance, fastest journey time, top speed, operator, rolling stock and a short story.
It's a solo project, and the interesting engineering wasn't the map — it was everything around it: keeping 700+ routes fast, making a JavaScript map discoverable by search engines and AI crawlers, and turning the whole thing into open data. Here's how it went together.
Everything derives from one flat list of routes. Each record is deliberately boring:
id, name, from, to, category, train, operator,
distance_km, top_speed_kmh, duration, opened,
pax_per_year, countries_iso, highlight, fame_rank
category
is one of high-speed · classic · night · scenic
, which drives the colour-coding. fame_rank
(1 = most famous) is a hand-curated renown ordering — it decides what you see first when the map is zoomed out, so the Shinkansen and the Orient Express surface before a regional branch line.
The geometry is separate: a hand-traced LineString
of lon/lat waypoints per route. I smooth those with a centripetal Catmull-Rom spline rather than a plain Catmull-Rom — the centripetal variant never overshoots hairpins like the Glacier Express' Chur reversal, which a naive spline turns into a loop.
The map is MapLibre GL JS on ** OpenFreeMap** vector tiles — a free, no-API-key, OpenStreetMap-based tile host. That combination means the whole map costs nothing to run and has no per-tile billing surprise.
Two things that mattered for making 700+ overlapping lines readable:
A single-page app is invisible to crawlers that don't run JavaScript — which includes most AI crawlers. My homepage was a <div id="root"></div>
and nothing else.
So a build step prerenders ~1,500 static HTML pages — one per route, plus country hubs, city-pair pages, and index hubs — each with unique titles/descriptions, canonical URLs, Open Graph + Twitter cards, and JSON-LD (TouristTrip
, BreadcrumbList
, FAQPage
, ItemList
). The homepage #root
gets a static atlas index injected at build time; createRoot().render()
replaces it the moment the interactive map hydrates, so users get the app and crawlers get real content.
One subtlety worth stealing: don't stamp every page's lastmod with the build date. That teaches Google your sitemap is noise. Instead I hash each page's content and only bump
lastmod
when the content actually changes.Once the atlas is structured data, you can ask it questions. My favourite: ** the fastest train in every country** — ranked not by headline top speed but by the speed each country's quickest train
The results are counter-intuitive: China's Beijing–Shanghai averages 307 km/h over ~1,300 km; the Shanghai Maglev tops 431 km/h but averages just 225 as a 30 km airport hop; and the US's fastest train, the Acela, averages 109 km/h — behind 33 other countries. The page and its chart are generated from the same module that feeds the map, so it updates itself as the atlas grows.
Two things I'd recommend to anyone building a data-driven site:
The map is at ** trainrouter.com** and the data is at