Route generator for runners and cyclists — built on real topology, real terrain, real roads.
TrailForge generates tailored running and cycling routes from a start address, a target distance, and a target elevation gain. It combines the GraphHopper and OpenRouteService routing engines with OpenStreetMap terrain data and Open-Meteo elevation profiles — then scores each candidate route on surface quality, elevation match, nature proximity, and quietness, returning the best match with a GPX export ready for your GPS device.
- 14 session profiles across 4 sports (running, road cycling, gravel, MTB), each with its own scoring weights and routing preferences
- Dual mode — Performance (blue) favors road quality; Scenic (green) boosts nature proximity and quietness
- Round-trip and A→B routing with optional via-waypoints
- Slope gradient visualization on the map (green → yellow → orange → red for 0 → 10 %+)
- Interactive elevation profile with hover crosshair synchronized to the map marker
- Up to 6 route variants per search, selectable in the sidebar
- GPX 1.1 export compatible with Garmin, Wahoo, Suunto, and Komoot
- Feedback dataset (👍 / 👎 on every route) stored locally for future ML training
┌─────────────────────────────────────────────────────────────────┐
│ Browser (Next.js) │
│ │
│ ┌─────────────────┐ ┌──────────────────────────────┐ │
│ │ Sidebar UI │ │ Mapbox GL JS │ │
│ │ SessionForm │◄─Zustand─► (slope, arrows) │ │
│ │ RouteResult │ store │ ElevationProfile (SVG) │ │
│ └────────┬────────┘ └──────────────────────────────┘ │
│ │ POST /api/generate-route │
└───────────┼─────────────────────────────────────────────────────┘
│
┌───────────▼─────────────────────────────────────────────────────┐
│ Next.js API Routes │
│ │
│ /api/generate-route │
│ (route-generator.ts) │
└──────┬──────────┬──────────────────────────────────────────────┘
│ │
▼ ▼
GraphHopper OpenRouteService
(running) (cycling)
│ │
▼ ▼
Open-Meteo Overpass OSM
(elevation) (terrain tags)
Data flow summary:
- User submits a form →
POST /api/generate-route - Address is geocoded via Nominatim
- 6 round-trip candidates are fetched from GraphHopper (running) or ORS (cycling)
- Elevation is enriched via Open-Meteo (running) or taken from ORS response (cycling)
- Terrain quality is scored via Overpass (highway/surface tags), cached 1 h
- Candidates are ranked; the best is displayed with the full slope map, profile chart, and GPX export
| Tool | Version | Purpose |
|---|---|---|
| Node.js | ≥ 22 | JavaScript runtime |
| npm | ≥ 10 | Package manager |
| Git | any | Source control |
API keys required (free tiers available):
| Service | Variable | Where to get it |
|---|---|---|
| Mapbox GL JS | NEXT_PUBLIC_MAPBOX_TOKEN |
account.mapbox.com |
| GraphHopper | GRAPHHOPPER_API_KEY |
graphhopper.com/dashboard |
| OpenRouteService | ORS_API_KEY |
openrouteservice.org/dev/#/login |
git clone https://github.com/your-org/trailforge.git
cd trailforge
npm installCopy the example file and fill in your API keys:
cp .env.example .env.local| Variable | Required | Description |
|---|---|---|
NEXT_PUBLIC_MAPBOX_TOKEN |
Yes | Mapbox GL JS public token (starts with pk.) |
GRAPHHOPPER_API_KEY |
Yes | GraphHopper Directions API key, used by the legacy fallback engine |
ORS_API_KEY |
Recommended | OpenRouteService API key for cycling profiles and legacy fallback |
ADMIN_SECRET |
Optional | Bearer token for admin feedback export via GET /api/feedback |
GROWTHBOOK_API_HOST |
Optional | GrowthBook API host, defaults to https://cdn.growthbook.io in .env.example |
GROWTHBOOK_CLIENT_KEY |
Optional | GrowthBook SDK client key for precomputed flag routes |
FLAGS_SECRET |
Optional | Secret required by the Flags SDK when precomputing /[code] permutations |
Open-Meteo and Overpass are used without authentication. If GrowthBook variables are empty, the app still builds; feature-flag permutation pages are simply skipped locally.
npm run dev
# App available at http://localhost:3000trailforge/
├── app/
│ ├── api/
│ │ └── generate-route/route.ts # POST handler — validates body, calls generateRoute()
│ ├── layout.tsx # Root layout (Syne font, viewport meta)
│ └── page.tsx # Single-page app shell (sidebar + map)
│
├── components/
│ ├── map/
│ │ ├── ClientMapWrapper.tsx # Dynamic import wrapper to skip SSR for Mapbox
│ │ └── MapView.tsx # Mapbox GL JS map, all layers, route animation
│ └── sidebar/
│ ├── AddressInput.tsx # Nominatim autocomplete with ARIA listbox
│ ├── ElevationProfile.tsx # Interactive SVG elevation chart
│ ├── FeedbackButtons.tsx # 👍 / 👎 feedback recorder
│ ├── RouteResult.tsx # Post-generation panel (stats, variants, GPX)
│ ├── SessionForm.tsx # Route generation form (sport, profile, targets)
│ └── SidebarContainer.tsx # Mounts form + result simultaneously, toggles visibility
│
├── lib/
│ ├── feedback-store.ts # localStorage read/write for RouteFeedback[]
│ ├── gpx-export.ts # GPX 1.1 generation and browser download
│ ├── route-generator.ts # Core pipeline — geocode → fetch → elevate → score → rank
│ ├── session-profiles.ts # 14 session profiles with weights and routing params
│ ├── store.ts # Zustand AppStore — single flat store for all UI state
│ └── types.ts # Shared TypeScript types for the entire codebase
│
├── public/ # Static assets
├── .env.example # Environment variable template
├── next.config.ts # Next.js config (headers, image domains)
├── tailwind.config.ts # Tailwind CSS config
└── tsconfig.json # TypeScript config (strict, path aliases)
Generates one or more route candidates from a start address.
Request body (application/json):
| Field | Type | Required | Description |
|---|---|---|---|
address |
string |
Yes | Start address (geocoded via Nominatim) |
profileId |
string |
Yes | One of the 14 profile IDs (e.g. "running_long_hills") |
targetDistanceKm |
number |
Yes | Target route distance in km |
targetElevationM |
number |
Yes | Target elevation gain in metres (D+) |
waypoints |
[number, number][] |
No | Additional via-waypoints [lng, lat] |
endAddress |
string |
No | Different end address for A→B routing |
Success response (200):
{
"success": true,
"route": {
"best": { "distanceKm": 12.3, "ascendM": 420, ... },
"candidates": [...],
"profile": { "id": "running_long_hills", "name": "Long trail" }
}
}Error responses:
| Status | errorCode |
Cause |
|---|---|---|
400 |
UNKNOWN |
Missing or invalid body fields, unknown profileId |
422 |
NO_ROAD_NETWORK |
No routable roads near the start address |
422 |
IMPOSSIBLE_ELEVATION |
Requested D+ exceeds terrain maximum |
422 |
GEOCODING_FAILED |
Nominatim could not resolve the address |
500 |
UNKNOWN |
Unexpected server-side error |
| ID | Name | Sport | Type |
|---|---|---|---|
running_easy |
Sortie facile | Running | Endurance |
running_long |
Longue distance | Running | Endurance |
running_intervals |
Fractionné | Running | Speed |
running_trail |
Trail | Running | Trail |
running_long_hills |
Long avec côtes | Running | Hills |
cycling_road_endurance |
Endurance route | Road cycling | Endurance |
cycling_road_intervals |
Fractionné route | Road cycling | Speed |
cycling_road_climb |
Grimpée | Road cycling | Hills |
cycling_gravel_explore |
Gravel exploration | Gravel | Exploration |
cycling_gravel_long |
Gravel longue | Gravel | Endurance |
cycling_mtb_trail |
VTT trail | MTB | Trail |
cycling_mtb_enduro |
VTT enduro | MTB | Hills |
cycling_mtb_xc |
VTT XC | MTB | Speed |
cycling_mtb_dh |
VTT DH | MTB | Descent |
- Fork the repository and create a feature branch:
git checkout -b feat/my-feature - Follow the code standards documented in DEVELOPER_GUIDE.md
- Add JSDoc (English, Google standard) to any new exported symbols
- Test manually — start the dev server and verify the feature end-to-end
- Open a pull request with a clear description of the change and why it was made
For architecture questions or large changes, open an issue first to align on the approach.
MIT © 2024 TrailForge contributors. See LICENSE.