DEV Community

Wanloria
Wanloria

Posted on

How we built a travel guide from open data with Claude Code

Disclosure: this post is by the Wanloria team (free, no ads, no affiliate links). It was drafted with AI assistance and fact-checked against the live site, per dev.to's guidelines on AI-assisted posts.

TL;DR: Wanloria is a free, bilingual (ES/EN) travel web app: verified places in 71 cities, 1-3 day itineraries with times, "free things" lists, signposted hiking trails with GPX and a weekly "this weekend" page. We built it largely by pair-programming with Claude Code and Cursor. The interesting part isn't the AI, though. It's the rules we gave it so it couldn't make things up.

The problem: travel content is mostly unverifiable

Most "things to do in X" pages are rewritten from other "things to do in X" pages. We wanted every fact on a page (opening hours, price, website, whether a trail is circular) to trace back to a source, or not be shown at all.

Data sources

  • OpenStreetMap for places and trail geometry (ODbL, credited on every trail page).
  • Wikidata / Wikipedia for identity and context. Place URLs literally carry the ID: /lugar/wiki-Q1140249/templo-de-debod and /lugar/osm-r4544509/museo-cerralbo.
  • Official websites for opening hours and prices, stored together with the URL they came from.
  • Open-Meteo elevation (90 m DEM) for elevation profiles.

Rule 1: the LLM writes prose, not facts

Descriptions are generated by an LLM only from the verified fields of that place, never copying sentences from the source. If a fact isn't verified, the template doesn't render it. Every description is reviewed before it's published.

Rule 2: computed numbers use published formulas

Hiking time follows DIN 33466, with no stops:

// DIN 33466: 4 km/h on the flat, 300 m/h up, 500 m/h down.
function dinHours(km, ascentM, descentM) {
  const horizontal = km / 4;
  const vertical = ascentM / 300 + descentM / 500;
  return Math.max(horizontal, vertical) + Math.min(horizontal, vertical) / 2;
}
dinHours(12.6, 493, 395); // 4.37 h → shown as "4 h 20 min"
Enter fullscreen mode Exit fullscreen mode

That's exactly what the live page for the PR-C 164 near Barcelona shows (12.6 km, +493 m, 4 h 20 min, MIDE 3/5). Difficulty uses the MIDE 1-5 effort scale. Trails are only published if they're signposted (GR/PR/SL or equivalent), within 18 km of the city, doable in a day, and if the OSM geometry matches the official distance. Loose stages of long-distance routes are dropped.

Itineraries split a city's top places by area, order each day from most important to nearest, and estimate walking as straight-line distance + 30 % for streets at 4.5 km/h, with a lunch break. Visit duration is estimated by place type (a big museum isn't a viewpoint) and adjusted when the place publishes its own figure.

Rule 3: pages for humans and for crawlers

The app itself is a SPA, but every public page (city, place, trail, 1/2/3-day plan, collections like /gratis and /museos, and /este-finde) is server-rendered HTML with JSON-LD (TouristAttraction, ItemList, FAQPage, BreadcrumbList), hreflang es/en and a sitemap of ~7,000 URLs. There's also an llms.txt and a llms-full.txt with the core facts per city, each linking to its page.

What still goes wrong (honestly)

Verification is only as good as the field it verifies. One real example: a museum whose general ticket costs €3 but is free on Sundays got tagged "free", because the source said "entrada gratuita…" for certain time slots. The fix we're moving to is a richer price model (free, free_at_times, paid) rather than a boolean. Elevation from a 90 m model can be ±10 % off a GPS track. Opening hours drift, so every place page keeps the source link and users can report errors to hola@wanloria.cloud.

Try it / steal the ideas

Feedback very welcome, especially on the price model and the trail filters.

Top comments (0)