November 29, 202510 min

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.

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

run-route-planner

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.