Skip to content

Use Maptoolkit Maps in Vue

A map in Vue 3 is a template ref for the container, a shallowRef for the map instance, and the onMounted and onBeforeUnmount hooks. The shallowRef is not a style preference; a plain ref will cause real problems.

The component

<script setup>
import { ref, shallowRef, onMounted, onBeforeUnmount } from "vue";
import { Map } from "@maptoolkit/maps";
import "@maptoolkit/maps/css";

const container = ref(null);
const map = shallowRef(null);

onMounted(() => {
  map.value = new Map({
    container: container.value,
    apiKey: import.meta.env.VITE_MAPTOOLKIT_KEY,
    center: [16.37, 48.21],
    zoom: 12,
  });
});

onBeforeUnmount(() => {
  map.value?.remove();
  map.value = null;
});
</script>

<template>
  <div ref="container" class="map"></div>
</template>

<style scoped>
.map {
  width: 100%;
  height: 400px;
}
</style>

Why shallowRef and not ref

ref() makes its contents deeply reactive: Vue walks the object and wraps every nested property in a Proxy. A map instance is a large graph with circular references, live WebGL handles and internal caches that change on every frame. Handing it to ref() means Vue proxies all of it.

The results range from a map that renders at a fraction of the expected frame rate to internal identity checks failing, because the library compares an object against what is now a Proxy of that object and finds them unequal. The symptoms look like library bugs and are not.

shallowRef() makes only the .value assignment reactive and leaves the instance untouched, which is exactly what you want: you need to know when the map is created, not when its internal tile cache changes. The same applies to markers, popups and any other library object you keep around. markRaw() is the equivalent escape hatch for an object you must put inside a reactive structure.

The container needs a height

A div with no height is zero pixels tall and the map initializes into nothing. No error, no map. In a scoped style block this is easy to miss, because the rule lives somewhere other than the element it applies to.

Reacting to changes

Drive the map imperatively from a watcher rather than recreating it:

watch(selected, (next) => {
  map.value?.flyTo({ center: next.coords, zoom: 14 });
});

For objects that belong to the map, create them in the watcher and clean them up with the cleanup callback:

watch(selected, (next, prev, onCleanup) => {
  if (!map.value) return;
  const marker = new Marker().setLngLat(next.coords).addTo(map.value);
  onCleanup(() => marker.remove());
});

Waiting for the style

Adding a source or a layer before the style has loaded throws “Style is not done loading”. Wait for the event:

map.value.on("load", () => {
  map.value.addSource("route", { type: "geojson", data: routeGeoJSON });
  map.value.addLayer({ id: "route", type: "line", source: "route" });
});

Nuxt

Nuxt server renders by default, and the library touches window at import time, so the import throws on the server. Keep the map out of the server bundle: name the component with a .client.vue suffix, or wrap it in <ClientOnly>. Give the <ClientOnly> fallback the same height as the map so the page does not shift when it appears.

The API key

VITE_ variables are inlined into the client bundle at build time, so the key is readable in the browser. That is inherent to a map key, because the browser sends it with every tile request. Restrict it to your domains rather than trying to hide it. A variable without the VITE_ prefix is not exposed to client code and arrives as undefined, which shows up as a 403 Access denied! from the tile host.

Next steps