Most Vue+Leaflet tutorials use the vue-leaflet wrapper, but in this GIS project, I built a more modular and flexible integration using vanilla Leaflet directly in Vue 3 components. This allowed me to support complex use cases like:
Multiple map tile layers with seamless toggling
Dozens of dynamic markers rendered from backend data
Rich popup UIs using Vue components rendered to HTML
Support for multiple
L.MapinstancesFull role-based dynamic layer rendering
This guide explains how I did it.
1. Rendering Markers Dynamically with Vue
Instead of vue-leaflet, I created a custom MapMarker.vue component that:
Injects a shared Leaflet
MapinstanceCreates a
L.markerinstance dynamicallyUpdates position reactively
Adds click event listeners
<script setup lang="ts">
import { inject, ref, watchEffect, onUnmounted } from 'vue';
import L from 'leaflet';
const props = defineProps<{ lat: number; lng: number; iconUrl?: string }>();
const emit = defineEmits(['click']);
const map = inject('MapContext'); // Provided by parent
const marker = ref<L.Marker | null>(null);
watchEffect(() => {
if (!map || marker.value) return;
const icon = props.iconUrl ? L.icon({ iconUrl: props.iconUrl, iconSize: [32, 32] }) : undefined;
marker.value = L.marker([props.lat, props.lng], { icon }).addTo(map);
marker.value.on('click', (e) => emit('click', e));
});
onUnmounted(() => {
marker.value?.remove();
});
</script>Switching Between Two Base Maps
I have added support for switching between a roadmap and satellite map using bringToFront() on tile layers.
const currentMap = ref<'road' | 'satellite'>('road');
const toggleBaseMap = () => {
if (currentMap.value === 'road') {
satelliteLayer.bringToFront();
currentMap.value = 'satellite';
} else {
roadLayer.bringToFront();
currentMap.value = 'road';
}
};Rendering Vue Popups on Leaflet Map
A common problem with Leaflet is integrating rich Vue content inside popups. I solved this by using a utility function to render a Vue component into static HTML, then injecting it into the Leaflet popup.
// render-utils.ts
import { renderToString } from 'vue/server-renderer';
import { h } from 'vue';
export async function renderComponentToHtml(component: any, props: Record<string, any>) {
const vnode = h(component, props);
const html = await renderToString(vnode);
const el = document.createElement('div');
el.innerHTML = html;
return { el };
}const showPopup = async (map, latlng, data) => {
const popupContent = await renderComponentToHtml(MyPopupComponent, { data });
L.popup().setLatLng(latlng).setContent(popupContent.el).openOn(map);
};
Supporting Multiple Leaflet Maps
I have scoped each map instance to its own container and provided the context using Vue’s dependency injection:
<script setup lang="ts">
import { provide, onMounted, ref } from 'vue';
import L from 'leaflet';
const map = ref<L.Map | null>(null);
onMounted(() => {
map.value = L.map('map-id').setView([-42, 146], 7);
provide('MapContext', map.value);
});
</script>
<template>
<div id="map-id" style="height: 500px;"></div>
<slot />
</template>Why Avoid vue-leaflet?
Customization over abstraction
Better control over Leaflet panes and z-indexing
Easier integration with Vue Teleport and composition API
No dependency on wrapper bugs or limitations
With Vue 3's Composition API and Leaflet’s flexibility, you don’t need heavy wrappers like vue-leaflet. This custom approach enabled me to:
Support multi-map instances
Render dynamic markers from backend
Show Vue-powered popups with interactive buttons
Toggle map layers conditionally based on user role
This method is especially powerful for enterprise GIS systems where performance and fine-grained control matter.
Originally published on Substack. Original publication date retained.