Why I built it I've always had an interest in multi-day hikes, ultralight backpacking and the outdoors. My biggest hurdle was always the planning — finding a route, working out what gear to bring, sorting sleep and resupply spots, and getting there and back. So I built the tool I wanted — an interactive map, a packing-weight planner, multi-person trip planning, an optional on-device LLM trail assistant, and offline support — end to end: Python/FastAPI + PostgreSQL/PostGIS on the back, React/TypeScript + MapLibre on the front. It is now live in production at nordictrails.dk, running on a small VPS behind Caddy — and it is built to self-host on hardware I own (a Raspberry Pi 5 and Jetson cluster) with no cloud dependency.
The problem I had
Planning a multi-day hike means answering four questions at once:
- What's worth walking, near me? Most trail information is scattered across PDFs, forums, national-park pages, and apps like AllTrails and Komoot.
- How do I get to the start — and back? Public transport to a trailhead is solvable but tedious to look up across Danish and Swedish operators; I wanted a quick, rough time estimate and travel plan.
- What do I carry? Pack weight is the difference between a good trip and a miserable one, and it changes with trip length, weather and season.
- Where do I sleep and resupply? On a multi-day route this is the real logistics problem — plenty of independent tools exist, but none that pull it together.
None of the apps I tried did all four for Denmark and southern Sweden, so I built one.
What it does
- Explore — a MapLibre map coupled to a filterable list (grade, country, nights, length, tags, rating, "near me" with a radius). Search runs server-side as full-text across the catalogue; the map clusters trailheads and auto-fits to results; filters live in the URL so a search is shareable.
- Hike detail — route + interactive elevation profile, sleep/food spots on the map, a day-by-day itinerary, weather + daylight, camping-legality, ratings and written reviews, a single "directions" menu wired to Google Maps / Rejseplanen (DK) / Skånetrafiken (SE), and an "ask about this trail" assistant.
- Planning — a per-trip workspace with a live pack-weight engine: gear vs consumables vs carried-by-others, per-trip quantities independent of what you own, and consumables that scale with trip length (fuel ~1 per 3 nights, food ~1 per day). Untracked-weight items are surfaced and counted separately, never silently dropped. Trips can be shared: invite co-hikers by email, assign who carries what, and get a per-person weight rollup. Download the trip as a GPX route plus a personalised PDF guide.
- Suggest a hike — trace a route by clicking waypoints or upload day-section GPX (auto-stitched); a traced route auto-fills distance and looks up elevation gain. Contributors can also submit moderated route variants — alternative GPX lines on an existing hike. Submissions queue for review.
- Community — a catalogue of thousands of Danish (GeoFA) and Swedish (Naturvårdsverket) facility spots as zoom-gated map overlays, kept fresh by a weekly re-sync; plus a light social layer — follow other hikers, public profiles, and a notification centre. The whole interface is in Danish, Swedish and English.
How it's built
Backend — Python / FastAPI, fully async. FastAPI with async SQLAlchemy 2.0 over PostgreSQL, with PostGIS for the geospatial work (GeoAlchemy2 + Shapely; GPX parsing via gpxpy). Async matters here because almost every request is I/O-bound — database, tile lookups, and third-party calls to weather/transit/elevation services — so the event loop stays busy instead of blocking on the network.
Geospatial data is first-class. Routes are real geometries, not lists of points: PostGIS handles trailhead clustering, "near me" distance sorting, and "search this area" viewport queries on the database side rather than in the browser. Click-traced routes carry no altitude, so the server estimates ascent through an elevation lookup at trace time, and route snapping runs against a self-hosted OpenRouteService instance, so that stays in-house too.
Heavy work runs off the request path. PDF trip-guide generation (route map, per-night itinerary, weather, packing list) is offloaded to a separate arq worker over Redis. The API enqueues a job and returns immediately; the worker renders and writes to a shared output directory the download endpoint reads from. A user never waits on a blocking request for a PDF.
Auth. JWT access tokens with rotating refresh tokens — refresh tokens rotate on every use and are revoked on logout, so a leaked token has a short, self-limiting life.
Frontend — React + TypeScript + Vite + MapLibre. MapLibre (open vector/raster tiles, no proprietary map SDK) is the spine of the UI; the list and the map are driven by one shared filter state, so hovering a card highlights its route and vice-versa. It's a PWA with offline tile caching and a "download for offline" flow, because trailheads are exactly where you lose signal. The interface is internationalised (en / da / sv).
The trail assistant can run on-device. "Ask about this trail" is a small retrieval-augmented (RAG) feature: trail content is embedded and answers are generated by a language model. The provider is pluggable and configurable at runtime — a local Ollama model (for example served on a Jetson Orin Nano), a hosted API, or none at all. Run against the local model, no trail data or user question leaves the network, which keeps it GDPR-clean by construction and free to run; and if no model is configured, the feature simply isn't offered.
The design principle I value most: graceful degradation
The platform integrates several optional external services — transit (Rejseplanen / Resrobot), weather + daylight, elevation (Open-Meteo), reverse-geocoding (Nominatim), static-map tiles for PDFs, and the local LLM. Every one of them is optional and degrades gracefully. If a key is missing or a provider is down, that feature quietly disappears and the rest of the app works. Transit becomes a plain "getting there" note; the LLM assistant just isn't offered; the PDF omits its map. No request fails because an upstream service is unavailable.
That single rule — optional integrations must fail soft — is what makes the platform cheap to run and portable: from a small VPS to hardware I own on a home connection, without constant operational attention.
Running it
The whole stack runs under Docker Compose (Postgres/PostGIS, Redis, the API, the PDF worker, the web build, a self-hosted OpenRouteService, migrations) behind Caddy, which terminates TLS and serves the site. Production lives on a small VPS at nordictrails.dk; the same compose stack is built to self-host on hardware I own — a 3-node Raspberry Pi 5 cluster with a Jetson Orin Nano for local inference — with no cloud dependency. Schema changes go through Alembic (ninety-plus migrations and counting). There's a Playwright end-to-end test suite and a frontend coverage floor enforced in CI, so a change that drops coverage or breaks a user flow fails the build.
Status and what this demonstrates
It is live and public at nordictrails.dk. It stays a private-source product, but I wanted to share the thinking and the design: a real system with a relational and geospatial data model, an async API, a background-job pipeline, a modern offline-capable frontend, an applied language model, and an operations story that runs on a single small server and can move to hardware at home.
I built it for myself — to use and to learn — and I think it turned out well enough to be worth sharing, in case others find it useful too.