Run Route Planner with Elevation Profiles in Nuxt 4 + TypeScript + Mapbox

Build a runner-friendly route planner with real-time elevation profiles using Nuxt 4, Mapbox GL JS v3 terrain, and the Directions API.
Tags: Nuxt 4, TypeScript, Mapbox GL JS, Directions API, Elevation
Time to read: 10 min
What’s new: We’ll use Mapbox GL JS v3’s terrain with queryTerrainElevation for smooth, client-side elevation sampling along a walking route.
Prerequisites
- Familiarity with the command line
- Node.js 18+
- A Mapbox account and a public access token with Directions API enabled
1) Create a Nuxt 4 app
If you don’t have a Nuxt 4 project yet:
npx nuxi init nuxt4-run-planner
cd nuxt4-run-planner
npm install
Run the dev server:
npm run dev
Open http://localhost:3000.
2) Install dependencies
We’ll use Mapbox GL JS for the map and terrain, and Chart.js for a small elevation chart.
npm i mapbox-gl chart.js
Mapbox GL JS includes its own TypeScript types.
3) Configure your Mapbox token
Add your token to a .env file (create one at the project root):
NUXT_PUBLIC_MAPBOX_TOKEN=pk.YOUR_MAPBOX_PUBLIC_TOKEN
Update nuxt.config.ts:
// nuxt.config.ts
export default defineNuxtConfig({
devtools: { enabled: true },
typescript: { strict: true },
runtimeConfig: {
public: {
mapboxToken: process.env.NUXT_PUBLIC_MAPBOX_TOKEN || ''
}
},
app: {
head: {
link: [
{ rel: 'preconnect', href: 'https://api.mapbox.com' },
{ rel: 'preconnect', href: 'https://events.mapbox.com' }
]
}
}
});
Restart the dev server after creating the .env file.
4) Create a client-only Map component with terrain + routing
Create components/RunPlannerMap.client.vue:
<script setup lang="ts">
import { onMounted, onBeforeUnmount, ref, defineExpose } from 'vue'
import mapboxgl, { Map, Marker } from 'mapbox-gl'
import type { GeoJSONSource } from 'mapbox-gl'
import 'mapbox-gl/dist/mapbox-gl.css'
type LngLat = { lng: number; lat: number }
type RouteProfile = {
distancesKm: number[]
elevationsM: number[]
totalAscentM: number
totalDescentM: number
lengthKm: number
}
const props = defineProps<{
accessToken: string
}>()
const emit = defineEmits<{
(e: 'profile', profile: RouteProfile): void
}>()
const mapContainer = ref<HTMLDivElement | null>(null)
let map: Map | null = null
let startMarker: Marker | null = null
let endMarker: Marker | null = null
function waitForIdle(m: Map) {
return new Promise<void>((resolve) => m.once('idle', () => resolve()))
}
function haversineKm(a: LngLat, b: LngLat): number {
const R = 6371
const dLat = (b.lat - a.lat) * Math.PI / 180
const dLng = (b.lng - a.lng) * Math.PI / 180
const lat1 = a.lat * Math.PI / 180
const lat2 = b.lat * Math.PI / 180
const sinDLat = Math.sin(dLat / 2)
const sinDLng = Math.sin(dLng / 2)
const h = sinDLat * sinDLat + Math.cos(lat1) * Math.cos(lat2) * sinDLng * sinDLng
return 2 * R * Math.asin(Math.sqrt(h))
}
async function fetchRoute(start: LngLat, end: LngLat) {
const base = 'https://api.mapbox.com/directions/v5/mapbox/walking'
const coords = `${start.lng},${start.lat};${end.lng},${end.lat}`
const params = new URLSearchParams({
access_token: props.accessToken,
geometries: 'geojson',
overview: 'full',
steps: 'false',
annotations: 'distance'
})
const url = `${base}/${coords}?${params.toString()}`
const res = await fetch(url)
if (!res.ok) throw new Error(`Directions error: ${res.status} ${res.statusText}`)
const data = await res.json()
const route = data.routes?.[0]
if (!route) throw new Error('No route found')
return route as {
geometry: { type: 'LineString'; coordinates: [number, number][] }
distance: number
duration: number
}
}
function addOrUpdateRouteSource(geometry: { type: 'LineString'; coordinates: [number, number][] }) {
if (!map) return
const sourceId = 'route'
const existing = map.getSource(sourceId) as GeoJSONSource | undefined
const feature = {
type: 'Feature',
geometry,
properties: {}
}
if (existing) {
existing.setData(feature as any)
} else {
map.addSource(sourceId, { type: 'geojson', data: feature as any })
map.addLayer({
id: 'route-line',
type: 'line',
source: sourceId,
paint: {
'line-color': '#3b82f6',
'line-width': 5
},
layout: { 'line-cap': 'round', 'line-join': 'round' }
})
}
}
async function computeElevationProfile(coords: [number, number][]) {
if (!map) return
// wait for terrain tiles to load for the current viewport
await waitForIdle(map)
const elevationsM: number[] = []
const distancesKm: number[] = [0]
let totalAscentM = 0
let totalDescentM = 0
let totalKm = 0
// Densify to ~30m spacing for smoother sampling
const sampled: [number, number][] = []
const maxSegmentMeters = 30
for (let i = 0; i < coords.length - 1; i++) {
const a = { lng: coords[i][0], lat: coords[i][1] }
const b = { lng: coords[i + 1][0], lat: coords[i + 1][1] }
sampled.push([a.lng, a.lat])
const segKm = haversineKm(a, b)
const segM = segKm * 1000
const steps = Math.max(0, Math.floor(segM / maxSegmentMeters) - 1)
if (steps > 0) {
for (let s = 1; s <= steps; s++) {
const t = s / (steps + 1)
sampled.push([
a.lng + (b.lng - a.lng) * t,
a.lat + (b.lat - a.lat) * t
])
}
}
}
sampled.push(coords[coords.length - 1])
let prevElev: number | null = null
let prev: LngLat | null = null
for (let i = 0; i < sampled.length; i++) {
const lng = sampled[i][0]
const lat = sampled[i][1]
const elev = map.queryTerrainElevation({ lng, lat }, { exaggerated: false })
const elevM = typeof elev === 'number' ? Math.max(0, Math.round(elev)) : 0
elevationsM.push(elevM)
if (prev) {
const dKm = haversineKm(prev, { lng, lat })
totalKm += dKm
distancesKm.push(Number(totalKm.toFixed(3)))
}
if (prevElev !== null) {
const delta = elevM - prevElev
if (delta > 0) totalAscentM += delta
else totalDescentM += Math.abs(delta)
}
prev = { lng, lat }
prevElev = elevM
}
emit('profile', {
distancesKm,
elevationsM,
totalAscentM: Math.round(totalAscentM),
totalDescentM: Math.round(totalDescentM),
lengthKm: Number(totalKm.toFixed(3))
})
}
function setMarker(which: 'start' | 'end', lngLat: mapboxgl.LngLat) {
const color = which === 'start' ? '#22c55e' : '#ef4444'
let marker = which === 'start' ? startMarker : endMarker
if (!marker) {
marker = new mapboxgl.Marker({ color, draggable: true })
.setLngLat(lngLat)
.addTo(map as Map)
.on('dragend', () => {
plan().catch(console.error)
})
if (which === 'start') startMarker = marker
else endMarker = marker
} else {
marker.setLngLat(lngLat)
}
}
async function plan() {
if (!startMarker || !endMarker) return
const start = startMarker.getLngLat()
const end = endMarker.getLngLat()
const route = await fetchRoute({ lng: start.lng, lat: start.lat }, { lng: end.lng, lat: end.lat })
addOrUpdateRouteSource(route.geometry)
// Fit bounds and wait for tiles, then compute elevation
if (map) {
const bounds = new mapboxgl.LngLatBounds()
route.geometry.coordinates.forEach(([lng, lat]) => bounds.extend([lng, lat]))
map.fitBounds(bounds, { padding: 60, duration: 600 })
}
await computeElevationProfile(route.geometry.coordinates)
}
function reset() {
if (!map) return
if (startMarker) { startMarker.remove(); startMarker = null }
if (endMarker) { endMarker.remove(); endMarker = null }
if (map.getLayer('route-line')) map.removeLayer('route-line')
if (map.getSource('route')) map.removeSource('route')
}
defineExpose({ reset })
onMounted(() => {
mapboxgl.accessToken = props.accessToken
map = new mapboxgl.Map({
container: mapContainer.value as HTMLDivElement,
style: 'mapbox://styles/mapbox/outdoors-v12',
center: [-122.447303, 37.753574],
zoom: 12,
pitch: 50
})
map.addControl(new mapboxgl.NavigationControl(), 'top-right')
map.on('load', () => {
if (!map) return
// DEM + terrain (Mapbox GL JS v3)
if (!map.getSource('mapbox-dem')) {
map.addSource('mapbox-dem', {
type: 'raster-dem',
url: 'mapbox://mapbox.mapbox-terrain-dem-v1',
tileSize: 512
} as any)
}
map.setTerrain({ source: 'mapbox-dem', exaggeration: 1.0 })
map.addLayer({
id: 'sky',
type: 'sky',
paint: {
'sky-type': 'atmosphere',
'sky-atmosphere-sun-intensity': 10
}
})
// Click to set start/end. Drag to refine.
map.on('click', (e) => {
if (!startMarker) {
setMarker('start', e.lngLat)
} else if (!endMarker) {
setMarker('end', e.lngLat)
plan().catch(console.error)
} else {
setMarker('end', e.lngLat)
plan().catch(console.error)
}
})
})
})
onBeforeUnmount(() => {
map?.remove()
map = null
})
</script>
<template>
<div class="map-wrap">
<div ref="mapContainer" class="map-container" />
<div class="hint">
Click map to set start and finish. Drag markers to refine.
</div>
</div>
</template>
<style scoped>
.map-wrap { position: relative; width: 100%; height: 60vh; border-radius: 12px; overflow: hidden; }
.map-container { width: 100%; height: 100%; }
.hint { position: absolute; left: 12px; bottom: 12px; background: rgba(0,0,0,0.55); color: #fff; padding: 6px 10px; border-radius: 8px; font-size: 12px; }
</style>
Notes:
- We use Mapbox GL JS v3 terrain with raster-dem and queryTerrainElevation to get elevations (in meters).
- We request geometries=geojson from the Directions API to avoid decoding polylines.
5) Create a tiny elevation chart component
Create components/ElevationChart.client.vue:
<script setup lang="ts">
import { onMounted, onBeforeUnmount, ref, watch } from 'vue'
import { Chart, type ChartData, type ChartOptions } from 'chart.js/auto'
const props = defineProps<{
distancesKm: number[]
elevationsM: number[]
}>()
const canvasRef = ref<HTMLCanvasElement | null>(null)
let chart: Chart | null = null
function render() {
if (!canvasRef.value) return
const data: ChartData<'line'> = {
labels: props.distancesKm.map(d => `${d.toFixed(2)} km`),
datasets: [
{
label: 'Elevation (m)',
data: props.elevationsM,
fill: true,
borderColor: '#3b82f6',
backgroundColor: 'rgba(59,130,246,0.15)',
pointRadius: 0,
tension: 0.25,
borderWidth: 2
}
]
}
const options: ChartOptions<'line'> = {
plugins: {
legend: { display: false },
tooltip: { mode: 'index', intersect: false }
},
scales: {
x: { ticks: { maxTicksLimit: 8, color: '#64748b' }, grid: { display: false } },
y: { title: { display: true, text: 'm' }, ticks: { color: '#64748b' }, grid: { color: 'rgba(100,116,139,0.15)' } }
},
responsive: true,
maintainAspectRatio: false
}
if (chart) chart.destroy()
chart = new Chart(canvasRef.value, { type: 'line', data, options })
}
watch(() => [props.distancesKm, props.elevationsM], () => render(), { deep: true })
onMounted(render)
onBeforeUnmount(() => chart?.destroy())
</script>
<template>
<div class="chart-wrap">
<canvas ref="canvasRef" />
</div>
</template>
<style scoped>
.chart-wrap { width: 100%; height: 220px; }
</style>
6) Build the page
Create pages/run-planner.vue:
<script setup lang="ts">
import { ref } from 'vue'
const config = useRuntimeConfig()
const token = config.public.mapboxToken as string
type Profile = {
distancesKm: number[]
elevationsM: number[]
totalAscentM: number
totalDescentM: number
lengthKm: number
}
const profile = ref<Profile | null>(null)
const mapRef = ref<any | null>(null)
function onProfile(p: Profile) {
profile.value = p
}
function resetAll() {
profile.value = null
mapRef.value?.reset()
}
</script>
<template>
<div class="page">
<h1>Run Route Planner with Elevation Profiles (Nuxt 4 + TypeScript + Mapbox)</h1>
<p class="lead">
Click to set start and finish. Drag markers to tweak the route. The elevation profile updates in real time.
</p>
<div v-if="!token" class="warn">
Missing NUXT_PUBLIC_MAPBOX_TOKEN. Create a .env file and restart the dev server.
</div>
<RunPlannerMap
ref="mapRef"
v-if="token"
:access-token="token"
@profile="onProfile"
/>
<section v-if="profile" class="stats">
<div class="stat">
<div class="label">Distance</div>
<div class="value">{{ profile.lengthKm.toFixed(2) }} km</div>
</div>
<div class="stat">
<div class="label">Ascent</div>
<div class="value">+{{ profile.totalAscentM }} m</div>
</div>
<div class="stat">
<div class="label">Descent</div>
<div class="value">-{{ profile.totalDescentM }} m</div>
</div>
<button class="reset" @click="resetAll">Reset</button>
</section>
<ElevationChart
v-if="profile"
:distances-km="profile.distancesKm"
:elevations-m="profile.elevationsM"
/>
<p class="tip">
Tip: Try the "outdoors" style for contour context, or "satellite-streets" for aerial detail.
</p>
</div>
</template>
<style scoped>
.page { display: grid; gap: 16px; }
.lead { color: #475569; }
.warn { padding: 10px 12px; border-radius: 8px; background: #fff7ed; color: #9a3412; }
.stats { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
.stat { background: #f8fafc; padding: 10px 12px; border-radius: 10px; min-width: 130px; }
.label { font-size: 12px; color: #64748b; }
.value { font-weight: 600; font-size: 16px; color: #0f172a; }
.reset { margin-left: auto; background: #ef4444; color: white; border: 0; padding: 8px 12px; border-radius: 8px; cursor: pointer; }
.tip { font-size: 12px; color: #64748b; }
</style>
Nuxt automatically registers components from the components directory, and *.client.vue files render only on the client where Mapbox and Chart.js are available.
7) Try it out
- Start your dev server:
npm run dev - Open http://localhost:3000/run-planner
- Click to set start and finish points on the map
- Drag either marker to refine the route and watch the elevation chart update
Notes and tips
- Terrain: We add the raster-dem source (mapbox.mapbox-terrain-dem-v1) and enable terrain to make queryTerrainElevation return meters above sea level.
- Sampling: The route geometry is densified to ~30 m spacing for smoother elevation profiles without overwhelming the main thread.
- Distance: We compute cumulative distance using a haversine function (km).
- API costs: Each route request uses the Directions API. Cache responsibly or debounce requests if you add waypoints.
- Tokens: Lock your public token to localhost and your production domains in the Mapbox dashboard.
Optional enhancements
- Waypoints: Extend to multiple waypoints and re-run directions using the full coordinate list.
- Surface grade: Derive slope (%) between samples and color segments of the route accordingly.
- Export GPX: Convert the route and elevation samples to GPX for watches.
- Offline fallback: Combine with a PWA setup to cache styles/tiles around your city for demos.
Conclusion
You built a Nuxt 4 run planner that:
- Finds a walking route via the Directions API
- Samples terrain in the browser using Mapbox GL JS v3
- Visualizes an elevation profile with ascent/descent
This is a great base for a runner or cyclist toolkit—add waypoints, surfaces, or GPX export next.





