Introduction
Interactive maps have become essential components in many web applications today – from location-based services and real estate sites to weather applications and travel platforms. If you’re looking to add sophisticated mapping capabilities to your Vue application, LeafletJS offers one of the best solutions available.
In this comprehensive guide, I’ll walk you through integrating LeafletJS with a modern Vue 3 stack using TypeScript and Vite. This combination creates a powerful, type-safe, and developer-friendly foundation for building mapping applications.
Why This Stack?
This technology combination offers several distinct advantages:
- LeafletJS provides a lightweight yet feature-rich mapping library with excellent documentation and a mature ecosystem. Its extensive range of plugins and customization options make it suitable for projects of all sizes.
- Vue 3’s Composition API offers a flexible approach to component logic that makes complex map interactions more maintainable. The reactivity system pairs beautifully with dynamic maps that need to respond to user interactions and data changes.
- TypeScript ensures type safety throughout your application, which is particularly valuable when working with complex geographic data and the extensive LeafletJS API.
- Vite delivers an incredibly fast development experience with near-instantaneous hot module replacement, making the development workflow smoother when working with map components.
What We’ll Build
By the end of this guide, you’ll have a robust mapping application that:
- Initializes and displays interactive maps with custom configurations
- Manages markers with custom icons and popups
- Handles map events appropriately
- Integrates with Vue’s reactivity system
- Maintains type safety throughout the application
- Uses best practices for performance optimization
Whether you’re building a location finder, a data visualization tool, or just adding a simple map to your application, this guide will provide you with the foundation you need to create professional mapping experiences in your Vue applications.
Let’s begin by setting up our project environment and installing the necessary dependencies.
Initial Setup and Configuration
Setting up a Leaflet map in a Vue 3 project involves several key steps. Let’s walk through the process of creating a solid foundation for our mapping application.
Project Creation
First, we’ll create a new Vue 3 project using Vite’s TypeScript template:
npm create vite@latest my-map-app -- --template vue-ts
cd my-map-app
npm install
Next, we need to install Leaflet and its TypeScript definitions:
npm install leaflet @types/leaflet
Our project structure will look like this:
my-map-app/
├── src/
│ ├── components/
│ │ └── MapComponent.vue
│ ├── composables/
│ │ └── useMap.ts
│ ├── types/
│ │ └── map.ts
│ ├── App.vue
│ └── main.ts
└── package.json
Basic Configuration
Vite Configuration
Let’s set up our Vite configuration with path aliases for cleaner imports vite.config.js
:
// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': resolve(__dirname, 'src')
}
}
})
This configuration enables us to use @/
as a shorthand for the src
directory, making imports more concise and maintainable.
Environment Variables
Setting up environment variables helps manage configuration across different environments:
VITE_MAP_TILE_URL=https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png
VITE_MAP_ATTRIBUTION="© OpenStreetMap contributors"
These variables will be used for the map tile URL and attribution text, allowing us to change them easily without modifying code.
LeafletJS Integration
Installing TypeScript Definitions
The @types/leaflet
package provides comprehensive TypeScript definitions for LeafletJS, enabling autocompletion and type checking when working with Leaflet objects and methods.
Configuring Marker Icons
One common issue with Leaflet in module environments is that the default marker icons don’t load correctly. We need to fix this by configuring the marker icons manually in our src/main.ts
file:
// src/main.ts
import { createApp } from 'vue'
import App from './App.vue'
import L from 'leaflet'
import 'leaflet/dist/leaflet.css'
import markerIcon2x from 'leaflet/dist/images/marker-icon-2x.png'
import markerIcon from 'leaflet/dist/images/marker-icon.png'
import markerShadow from 'leaflet/dist/images/marker-shadow.png'
// Fix the missing icon issue
delete (L.Icon.Default.prototype as any)._getIconUrl
L.Icon.Default.mergeOptions({
iconUrl: markerIcon,
iconRetinaUrl: markerIcon2x,
shadowUrl: markerShadow
})
createApp(App).mount('#app')
This code imports the necessary marker icon assets and configures Leaflet to use them for the default markers. We delete the original _getIconUrl
method from the Icon.Default.prototype
before setting the icon URLs to ensure our configurations are used.
Asset Handling in Vite
To ensure Leaflet’s CSS is properly loaded, we import it in our main.ts
file:
import 'leaflet/dist/leaflet.css'
This stylesheet contains essential styling for Leaflet maps, controls, and markers. Importing it in main.ts
ensures it’s available throughout the application.
With these configurations in place, we’ve laid the groundwork for integrating Leaflet with Vue 3 and TypeScript. Our environment is now set up to handle maps, markers, and other geographic features with proper typing and asset management.
Core Implementation
The heart of our LeafletJS integration is building reusable map components that leverage Vue 3’s Composition API and TypeScript for type safety. Let’s walk through implementing the essential components of our mapping application.
Basic Map Component

Let’s create a foundational map component in src/components/MapComponent.vue
:
<template>
<div ref="mapContainer" class="map-container"></div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import L from 'leaflet'
const mapContainer = ref<HTMLElement | null>(null)
const map = ref<L.Map | null>(null)
onMounted(() => {
if (mapContainer.value) {
map.value = L.map(mapContainer.value).setView([51.505, -0.09], 13)
L.tileLayer(import.meta.env.VITE_MAP_TILE_URL || 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: import.meta.env.VITE_MAP_ATTRIBUTION || '© OpenStreetMap contributors'
}).addTo(map.value)
}
})
onUnmounted(() => {
// Clean up the map instance when the component is destroyed
map.value?.remove()
})
</script>
<style scoped>
.map-container {
height: 400px;
width: 100%;
}
</style>
This component:
- Creates a container div for the map with appropriate styling
- Uses Vue’s
ref
to store references to the DOM element and map instance - Initializes the map on component mount with a default center and zoom level
- Adds a tile layer using our environment variables
- Properly cleans up resources when the component is unmounted
TypeScript Integration
To enhance type safety and developer experience, let’s create type definitions for our map components:
// src/types/map.ts
import type { LatLngExpression, MapOptions } from 'leaflet'
export interface MapConfig {
center: LatLngExpression;
zoom: number;
maxZoom?: number;
minZoom?: number;
}
export interface MapMarker {
id: number | string;
position: LatLngExpression;
popup?: string;
icon?: L.Icon;
draggable?: boolean;
}
export interface MapEvents {
click: (e: L.LeafletMouseEvent) => void;
moveend: () => void;
zoomend: () => void;
}
Now, let’s update our map component to leverage these types src/components/MapComponent.vue
:
<template>
<div ref="mapContainer" class="map-container"></div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue'
import L from 'leaflet'
import type { MapConfig, MapMarker } from '@/types/map'
const props = defineProps<{
initialConfig: MapConfig;
markers?: MapMarker[];
}>()
const emit = defineEmits<{
(e: 'click', event: L.LeafletMouseEvent): void
(e: 'update:center', center: L.LatLng): void
(e: 'update:zoom', zoom: number): void
(e: 'markerClick', marker: MapMarker): void
}>()
const mapContainer = ref<HTMLElement | null>(null)
const map = ref<L.Map | null>(null)
onMounted(() => {
initializeMap()
})
onUnmounted(() => {
map.value?.remove()
})
function initializeMap() {
if (!mapContainer.value) return
map.value = L.map(mapContainer.value).setView(
props.initialConfig.center,
props.initialConfig.zoom
)
L.tileLayer(import.meta.env.VITE_MAP_TILE_URL || 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: import.meta.env.VITE_MAP_ATTRIBUTION || '© OpenStreetMap contributors',
maxZoom: props.initialConfig.maxZoom || 18,
minZoom: props.initialConfig.minZoom || 1
}).addTo(map.value)
setupEventHandlers()
updateMarkers()
}
function setupEventHandlers() {
if (!map.value) return
map.value.on('click', (e: L.LeafletMouseEvent) => {
emit('click', e)
})
map.value.on('moveend', () => {
if (map.value) {
emit('update:center', map.value.getCenter())
}
})
map.value.on('zoomend', () => {
if (map.value) {
emit('update:zoom', map.value.getZoom())
}
})
}
function updateMarkers() {
if (!map.value || !props.markers) return
props.markers.forEach(marker => {
const leafletMarker = L.marker(marker.position)
.addTo(map.value!)
if (marker.popup) {
leafletMarker.bindPopup(marker.popup)
}
leafletMarker.on('click', () => {
emit('markerClick', marker)
})
})
}
// Watch for changes to markers and update the map
watch(() => props.markers, updateMarkers, { deep: true })
</script>
<style scoped>
.map-container {
height: 400px;
width: 100%;
}
</style>
Composition API Implementation
To make our map logic more reusable across components, let’s extract it into a composable function:
// src/composables/useMap.ts
import { ref, onUnmounted, type Ref } from 'vue'
import L from 'leaflet'
import type { MapConfig, MapMarker } from '@/types/map'
export function useMap(container: Ref<HTMLElement | null>) {
const map = ref<L.Map | null>(null)
const markers = ref<L.Marker[]>([])
function initializeMap(config: MapConfig) {
if (container.value && !map.value) {
// Ensure config is defined and has required properties
if (!config) {
console.error('Map configuration is required')
return
}
// Use default values if properties are missing
const center = config.center || [0, 0]
const zoom = config.zoom || 13
const options = {
maxZoom: config.maxZoom,
minZoom: config.minZoom
}
map.value = L.map(container.value).setView(center, zoom, options)
addTileLayer()
}
}
function addTileLayer() {
if (!map.value) return
L.tileLayer(import.meta.env.VITE_MAP_TILE_URL || 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: import.meta.env.VITE_MAP_ATTRIBUTION || '© OpenStreetMap contributors'
}).addTo(map.value)
}
function addMarkers(items: MapMarker[]) {
if (!map.value) return
// Clear existing markers
clearMarkers()
// Add new markers
items.forEach(item => {
const marker = L.marker(item.position)
if (item.popup) {
marker.bindPopup(item.popup)
}
if (item.icon) {
marker.setIcon(item.icon)
}
marker.addTo(map.value!)
markers.value.push(marker)
})
}
function clearMarkers() {
markers.value.forEach(marker => marker.remove())
markers.value = []
}
function setView(center: L.LatLngExpression, zoom: number) {
map.value?.setView(center, zoom)
}
function getCurrentState() {
if (!map.value) return null
return {
center: map.value.getCenter(),
zoom: map.value.getZoom(),
bounds: map.value.getBounds()
}
}
const cleanupResources = () => {
// Remove all event listeners
if (map.value) {
map.value.off()
map.value.eachLayer(layer => {
if ('off' in layer) {
layer.off()
}
})
}
// Clear all layers
clearMarkers()
// Remove map
if (map.value) {
map.value.remove()
map.value = null
}
}
// Clean up on unmount
onUnmounted(() => {
cleanupResources()
})
return {
map,
markers,
initializeMap,
addMarkers,
clearMarkers,
setView,
getCurrentState,
cleanupResources
}
}
Now, let’s update our src/components/MapComponent.vue
to use this composable:
<template>
<div ref="mapContainer" class="map-container"></div>
</template>
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
import type { MapConfig, MapMarker } from '@/types/map'
import { useMap } from '@/composables/useMap'
import L from 'leaflet'
const props = defineProps<{
initialConfig: MapConfig;
markers?: MapMarker[];
}>()
const emit = defineEmits<{
(e: 'click', event: L.LeafletMouseEvent): void
(e: 'update:center', center: L.LatLng): void
(e: 'update:zoom', zoom: number): void
(e: 'markerClick', marker: MapMarker): void
}>()
const mapContainer = ref<HTMLElement | null>(null)
const { map, initializeMap, addMarkers } = useMap(mapContainer)
onMounted(() => {
initializeMap(props.initialConfig)
setupEventHandlers()
if (props.markers) {
addMarkers(props.markers)
}
})
function setupEventHandlers() {
if (!map.value) return
map.value.on('click', (e: L.LeafletMouseEvent) => {
emit('click', e)
})
map.value.on('moveend', () => {
if (map.value) {
emit('update:center', map.value.getCenter())
}
})
map.value.on('zoomend', () => {
if (map.value) {
emit('update:zoom', map.value.getZoom())
}
})
}
// Watch for changes to markers and update the map
watch(() => props.markers, (newMarkers) => {
if (newMarkers) {
addMarkers(newMarkers)
}
}, { deep: true })
</script>
<style scoped>
.map-container {
height: 400px;
width: 100%;
}
</style>
With this implementation, we have:
- A reusable
useMap
composable that encapsulates map functionality - Strong TypeScript typing for all map properties and events
- Reactive handling of props changes
- Proper cleanup of resources to prevent memory leaks
- Event handling that communicates map changes back to parent components
This core implementation provides a solid foundation for building more complex mapping features. The composable pattern makes it easy to reuse map logic across multiple components, and TypeScript ensures that we maintain type safety throughout our application.
Using the Map Component in Your Application
Now that we’ve built our MapComponent with TypeScript support and the useMap composable, let’s integrate it into our main application to display a working map.
<template>
<div class="app-container">
<h1>LeafletJS with Vue 3 and TypeScript</h1>
<MapComponent
:initialConfig="mapConfig"
:markers="mapMarkers"
@click="handleMapClick"
@markerClick="handleMarkerClick"
/>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import MapComponent from './components/MapComponent.vue'
import type { MapConfig, MapMarker } from './types/map'
import L from 'leaflet'
// Map configuration
const mapConfig: MapConfig = {
center: [51.505, -0.09], // London
zoom: 13,
maxZoom: 18,
minZoom: 3
}
// Sample markers
const mapMarkers = ref<MapMarker[]>([
{
id: 1,
position: [51.5, -0.09],
popup: 'Hello, I am a marker!'
},
{
id: 2,
position: [51.51, -0.1],
popup: 'Another interesting location'
}
])
// Event handlers
const handleMapClick = (event: L.LeafletMouseEvent) => {
console.log('Map clicked at:', event.latlng)
// Add a new marker on click (optional)
const newMarker: MapMarker = {
id: Date.now(),
position: [event.latlng.lat, event.latlng.lng],
popup: `New marker at ${event.latlng.lat.toFixed(4)}, ${event.latlng.lng.toFixed(4)}`
}
mapMarkers.value.push(newMarker)
}
const handleMarkerClick = (marker: MapMarker) => {
console.log('Marker clicked:', marker)
}
</script>
<style>
.app-container {
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
h1 {
text-align: center;
margin-bottom: 20px;
}
</style>
With this implementation:
- We’ve imported our MapComponent and necessary types
- Set up a basic map configuration centered on London
- Created sample markers with popup information
- Added event handlers for map clicks and marker clicks
- Demonstrated how to dynamically add markers when clicking on the map
When you run the application with npm run dev
, you’ll see a fully functional map displayed in your browser with the markers we defined. The map is interactive – you can zoom, pan, click on markers to see popups, and click anywhere on the map to add new markers.
Advanced Features
Now that we have a functional map component integrated into our application, let’s explore some advanced features that will enhance your mapping capabilities. This section focuses on practical enhancements you’ll likely need in real-world applications.
Understanding Vue 3 Composables
Before diving into advanced features, let’s briefly discuss the composable pattern we’re using. Vue 3’s Composition API allows us to extract and reuse stateful logic across components through functions called “composables.” These functions follow a convention of starting with “use” (e.g., useMap
, useMarkers
).
Composables allow us to:
- Organize code by logical concerns rather than lifecycle hooks
- Share complex functionality between components
- Maintain reactivity with Vue’s reactivity system
- Create cleaner, more maintainable code
Each composable typically contains:
- Reactive state (using
ref
orreactive
) - Functions that modify that state
- Lifecycle hooks if needed (like
onMounted
oronUnmounted
) - Return values exposing state and functions
With this understanding, let’s explore advanced mapping features through composables.
Interactive Features
Custom Controls
Adding custom controls to your map allows you to extend Leaflet’s functionality with your own UI elements src/composables/useMapControls.ts
:
// src/composables/useMapControls.ts
import L from 'leaflet'
import { type Ref } from 'vue'
export function useMapControls(map: Ref<L.Map | null>) {
const addCustomControl = (position: L.ControlPosition, content: string, className: string) => {
if (!map.value) return null
const CustomControl = L.Control.extend({
onAdd: () => {
const container = L.DomUtil.create('div', className)
container.innerHTML = content
// Prevent clicks from propagating to the map
L.DomEvent.disableClickPropagation(container)
return container
}
})
const control = new CustomControl({ position })
map.value.addControl(control)
return control
}
return { addCustomControl }
}
Usage in your component:
<template>
<div ref="mapContainer" class="map-container"></div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useMap } from '@/composables/useMap'
import { useMapControls } from '@/composables/useMapControls'
import type { MapConfig } from '@/types/map'
const props = defineProps<{
initialConfig: MapConfig;
}>()
const mapContainer = ref<HTMLElement | null>(null)
const { map, initializeMap } = useMap(mapContainer)
const { addCustomControl } = useMapControls(map)
onMounted(() => {
initializeMap(props.initialConfig)
addCustomControl('topright', '<button class="reset-btn">Center Map</button>', 'custom-control')
// You can access and add event listeners to the button after it's created
setTimeout(() => {
const button = document.querySelector('.reset-btn')
if (button) {
button.addEventListener('click', () => {
map.value?.setView(props.initialConfig.center, props.initialConfig.zoom)
})
}
}, 0)
})
</script>
<style scoped>
.map-container {
height: 400px;
width: 100%;
}
:deep(.custom-control) {
background-color: white;
padding: 5px;
border-radius: 4px;
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
}
:deep(.custom-control button) {
padding: 6px 10px;
border: none;
background: #4285f4;
color: white;
cursor: pointer;
border-radius: 3px;
}
:deep(.custom-control button:hover) {
background: #3367d6;
}
</style>
Advanced Event Handling
Optimizing event handling with debouncing to improve performance when handling frequent map events:
// src/utils/functions.ts
export function debounce<T extends (...args: any[]) => any>(
func: T,
wait: number
): (...args: Parameters<T>) => void {
let timeout: number | null = null
return function(...args: Parameters<T>) {
if (timeout !== null) {
clearTimeout(timeout)
}
timeout = window.setTimeout(() => {
func(...args)
}, wait)
}
}
// src/composables/useMapEvents.ts
import L from 'leaflet'
import { type Ref } from 'vue'
import { debounce } from '@/utils/functions'
export function useMapEvents(map: Ref<L.Map | null>) {
const setupDebouncedEvents = (events: Record<string, (e: L.LeafletEvent) => void>, delay = 300) => {
if (!map.value) return
Object.entries(events).forEach(([event, handler]) => {
const debouncedHandler = debounce(handler, delay)
map.value!.on(event, debouncedHandler)
})
}
return { setupDebouncedEvents }
}
Now, here’s how to integrate this into your MapComponent:
<template>
<div ref="mapContainer" class="map-container"></div>
</template>
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
import type { MapConfig, MapMarker } from '@/types/map'
import { useMap } from '@/composables/useMap'
import { useMapEvents } from '@/composables/useMapEvents'
import L from 'leaflet'
const props = defineProps<{
initialConfig: MapConfig;
markers?: MapMarker[];
}>()
const emit = defineEmits<{
(e: 'click', event: L.LeafletMouseEvent): void
(e: 'update:center', center: L.LatLng): void
(e: 'update:zoom', zoom: number): void
(e: 'markerClick', marker: MapMarker): void
(e: 'moveend', bounds: L.LatLngBounds): void
}>()
const mapContainer = ref<HTMLElement | null>(null)
const { map, initializeMap, addMarkers } = useMap(mapContainer)
const { setupDebouncedEvents } = useMapEvents(map)
onMounted(() => {
initializeMap(props.initialConfig)
setupEventHandlers()
if (props.markers) {
addMarkers(props.markers)
}
})
function setupEventHandlers() {
if (!map.value) return
// Regular event handling for click events (no debounce)
map.value.on('click', (e: L.LeafletMouseEvent) => {
emit('click', e)
})
// Setup debounced events for performance-sensitive operations
setupDebouncedEvents({
'moveend': () => {
if (!map.value) return
emit('update:center', map.value.getCenter())
emit('moveend', map.value.getBounds())
},
'zoomend': () => {
if (!map.value) return
emit('update:zoom', map.value.getZoom())
},
'resize': () => {
console.log('Map resized, updating layout...')
map.value?.invalidateSize()
}
}, 250) // 250ms debounce delay
}
// Watch for changes to markers and update the map
watch(() => props.markers, (newMarkers) => {
if (newMarkers) {
addMarkers(newMarkers)
}
}, { deep: true })
</script>
<style scoped>
.map-container {
height: 400px;
width: 100%;
}
</style>
Key Benefits of Debounced Event Handling
- Performance Improvement: By debouncing events like
moveend
andzoomend
, you prevent excessive handler calls during user interactions like panning or zooming the map. - Reduced Network Traffic: When map events trigger API calls or data fetching, debouncing reduces the number of requests sent during continuous operations like dragging the map.
- Smoother User Experience: Debouncing ensures UI updates happen at a reasonable rate, preventing choppy interactions or excessive DOM updates.
- Battery Life Preservation: On mobile devices, reducing the frequency of event handling can help preserve battery life during map interactions.
This approach keeps the MapComponent’s code clean while leveraging the power of composables to add sophisticated event handling. The debouncing implementation is particularly useful when your map interactions trigger expensive operations like API calls, state updates, or complex UI changes.
Data Management
GeoJSON Integration
Working with GeoJSON data is a common requirement for many mapping applications:
// src/composables/useGeoJson.ts
import L from 'leaflet'
import { ref, type Ref } from 'vue'
export function useGeoJson(map: Ref<L.Map | null>) {
const geoJsonLayers = ref<L.GeoJSON[]>([])
const addGeoJson = (data: any, options?: L.GeoJSONOptions) => {
if (!map.value) return null
const layer = L.geoJSON(data, options).addTo(map.value)
geoJsonLayers.value.push(layer)
return layer
}
const loadGeoJson = async (url: string, options?: L.GeoJSONOptions) => {
try {
const response = await fetch(url)
if (!response.ok) throw new Error('Failed to load GeoJSON')
const data = await response.json()
return addGeoJson(data, options)
} catch (error) {
console.error('Error loading GeoJSON:', error)
return null
}
}
const clearGeoJson = () => {
geoJsonLayers.value.forEach(layer => {
if (map.value) {
map.value.removeLayer(layer)
}
})
geoJsonLayers.value = []
}
return { addGeoJson, loadGeoJson, clearGeoJson, geoJsonLayers }
}
Custom Marker Management
Creating and managing markers with custom icons and interactions:
// src/composables/useMarkers.ts
import L from 'leaflet'
import { ref, type Ref } from 'vue'
import type { MapMarker } from '@/types/map'
export function useMarkers(map: Ref<L.Map | null>) {
const markers = ref<Record<string | number, L.Marker>>({})
const createIcon = (options: L.IconOptions) => {
return L.icon(options)
}
const addMarker = (marker: MapMarker) => {
if (!map.value) return null
// Create marker options object, only including icon if it exists
const markerOptions: L.MarkerOptions = {}
if (marker.draggable) {
markerOptions.draggable = marker.draggable
}
// Only add icon to options if it's defined
if (marker.icon) {
markerOptions.icon = marker.icon
}
const leafletMarker = L.marker(marker.position, markerOptions).addTo(map.value)
if (marker.popup) {
leafletMarker.bindPopup(marker.popup)
}
markers.value[marker.id] = leafletMarker
return leafletMarker
}
const removeMarker = (id: string | number) => {
const marker = markers.value[id]
if (marker && map.value) {
map.value.removeLayer(marker)
delete markers.value[id]
}
}
const updateMarkerPosition = (id: string | number, position: L.LatLngExpression) => {
const marker = markers.value[id]
if (marker) {
marker.setLatLng(position)
}
}
return {
markers,
createIcon,
addMarker,
removeMarker,
updateMarkerPosition
}
}
Performance Optimization
Layer Groups and Clustering

Optimize performance when dealing with large numbers of markers using clustering:
Note: This requires installing the additional package: npm install leaflet.markercluster @types/leaflet.markercluster
// src/composables/useCluster.ts
import L from 'leaflet'
import 'leaflet.markercluster'
import { ref, type Ref } from 'vue'
import type { MapMarker } from '@/types/map'
export function useCluster(map: Ref<L.Map | null>) {
const clusterGroup = ref<L.MarkerClusterGroup | null>(null)
const initializeCluster = (options?: L.MarkerClusterGroupOptions) => {
if (!map.value) return null
clusterGroup.value = L.markerClusterGroup(options || {
chunkedLoading: true,
spiderfyOnMaxZoom: true,
showCoverageOnHover: true,
zoomToBoundsOnClick: true
}).addTo(map.value)
return clusterGroup.value
}
const addMarkersToCluster = (markers: MapMarker[]) => {
if (!clusterGroup.value) {
initializeCluster()
}
markers.forEach(marker => {
// Create marker options object, only including icon if it exists
const markerOptions: L.MarkerOptions = {}
if (marker.draggable) {
markerOptions.draggable = marker.draggable
}
// Only add icon to options if it's defined
if (marker.icon) {
markerOptions.icon = marker.icon
}
const leafletMarker = L.marker(marker.position, markerOptions)
if (marker.popup) {
leafletMarker.bindPopup(marker.popup)
}
clusterGroup.value?.addLayer(leafletMarker)
})
}
const clearCluster = () => {
clusterGroup.value?.clearLayers()
}
return {
clusterGroup,
initializeCluster,
addMarkersToCluster,
clearCluster
}
}
Now create the component to display the new cluster map src/components/ClusterMapComponent.vue
:
<template>
<div>
<div class="controls">
<button @click="loadRandomMarkers(100)">Load 100 Markers</button>
<button @click="loadRandomMarkers(1000)">Load 1000 Markers</button>
<button @click="clearAllMarkers">Clear All</button>
</div>
<div ref="mapContainer" class="cluster-map"></div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useMap } from '@/composables/useMap'
import { useCluster } from '@/composables/useCluster'
import type { MapConfig, MapMarker } from '@/types/map'
import L from 'leaflet'
const mapContainer = ref<HTMLElement | null>(null)
const { map, initializeMap } = useMap(mapContainer)
const { initializeCluster, addMarkersToCluster, clearCluster } = useCluster(map)
const mapConfig: MapConfig = {
center: [0, 0],
zoom: 2
}
onMounted(() => {
initializeMap(mapConfig)
initializeCluster({
maxClusterRadius: 50,
disableClusteringAtZoom: 16
})
})
// Generate random markers for demonstration
function loadRandomMarkers(count: number) {
const markers: MapMarker[] = []
for (let i = 0; i < count; i++) {
// Generate random coordinates within a reasonable range
const lat = (Math.random() * 180) - 90
const lng = (Math.random() * 360) - 180
markers.push({
id: i,
position: [lat, lng],
popup: `Marker ${i} at ${lat.toFixed(4)}, ${lng.toFixed(4)}`
})
}
addMarkersToCluster(markers)
}
function clearAllMarkers() {
clearCluster()
}
</script>
<style>
.cluster-map {
height: 600px;
width: 100%;
}
.controls {
margin-bottom: 10px;
display: flex;
gap: 10px;
}
.controls button {
padding: 8px 16px;
background: #4285f4;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.controls button:hover {
background: #3367d6;
}
</style>
Add the component to our application in src/App.vue
:
<template>
<div class="app-container">
<h1>Marker Clustering Example</h1>
<ClusterMapComponent />
</div>
</template>
<script setup lang="ts">
import ClusterMapComponent from './components/ClusterMapComponent.vue'
</script>
The component demonstrates how to handle large numbers of markers efficiently using clustering. When you have many markers, they will be grouped into clusters based on the zoom level, significantly improving performance compared to rendering individual markers.
When a user zooms in, the clusters break apart into smaller clusters or individual markers, providing a smooth and responsive user experience even with thousands of points on the map.
This approach is particularly useful for applications that need to display large datasets like:
- Store locations
- User-generated content
- Sensor data
- Traffic incidents
- Wildlife tracking
By using marker clustering, you can maintain good performance while displaying large volumes of geographical data on your map.
Practical Implementation Examples
Interactive Map with Search Functionality

Combine the previous features to create a map with search capabilities src/components/SearchMapComponent.vue
:
<template>
<div class="map-container">
<div class="search-bar">
<input
v-model="searchQuery"
type="text"
placeholder="Search locations..."
@keyup.enter="searchLocation"
/>
<button @click="searchLocation">Search</button>
</div>
<div ref="mapContainer" class="map"></div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useMap } from '@/composables/useMap'
import { useMarkers } from '@/composables/useMarkers'
import type { MapConfig, MapMarker } from '@/types/map'
import L from 'leaflet'
const mapContainer = ref<HTMLElement | null>(null)
const { map, initializeMap } = useMap(mapContainer)
const { addMarker, removeMarker } = useMarkers(map)
const searchQuery = ref('')
const searchResult = ref<MapMarker | null>(null)
const mapConfig: MapConfig = {
center: [51.505, -0.09],
zoom: 13
}
onMounted(() => {
initializeMap(mapConfig)
})
const searchLocation = async () => {
if (!searchQuery.value.trim() || !map.value) return
try {
// Example using Nominatim search API (consider using a commercial API for production)
const response = await fetch(
`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(searchQuery.value)}`
)
const data = await response.json()
if (data && data.length > 0) {
const result = data[0]
// Remove previous search result marker
if (searchResult.value) {
removeMarker(searchResult.value.id)
}
// Create new marker for search result
searchResult.value = {
id: 'search-result',
position: [parseFloat(result.lat), parseFloat(result.lon)],
popup: result.display_name
}
// Add marker and center map
const marker = addMarker(searchResult.value)
marker?.openPopup()
map.value.setView(searchResult.value.position as L.LatLngExpression, 16)
} else {
alert('No results found')
}
} catch (error) {
console.error('Search error:', error)
alert('Error searching for location')
}
}
</script>
<style scoped>
.map-container {
position: relative;
height: 100%;
}
.search-bar {
position: absolute;
top: 20px;
left: 50%;
transform: translateX(-50%);
z-index: 1000;
display: flex;
gap: 8px;
}
.search-bar input {
padding: 8px 12px;
min-width: 300px;
border: 1px solid #ccc;
border-radius: 4px;
}
.search-bar button {
padding: 8px 16px;
background: #4285f4;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
.map {
height: 500px;
width: 100%;
}
</style>
Update the src/App.vue
to add the component to the application:
<template>
<div class="app-container">
<h1>Search Map Example</h1>
<SearchMapComponent />
</div>
</template>
<script setup lang="ts">
import SearchMapComponent from './components/SearchMapComponent.vue'
</script>
Dynamic Data Visualization

To do the dynamic data visualization we need to download some GeoJSON data:
mkdir -p public/data
curl -o public/data/us-states.json https://raw.githubusercontent.com/PublicaMundi/MappingAPI/refs/heads/master/data/geojson/us-states.json
Create a composable to help build our map that visualizes data with color-coded regions src/composables/useThematicMap.vue
:
import L from 'leaflet'
import { type Ref } from 'vue'
export function useThematicMap(map: Ref<L.Map | null>) {
const createColorScale = (min: number, max: number, colors: string[]) => {
return (value: number) => {
const range = max - min
const step = range / (colors.length - 1)
const index = Math.min(colors.length - 1, Math.floor((value - min) / step))
return colors[index]
}
}
const applyThematicStyle = (feature: any, getColor: (value: number) => string, valueProperty: string) => {
const value = feature.properties[valueProperty]
return {
fillColor: getColor(value),
weight: 1,
opacity: 0.7,
color: '#666',
fillOpacity: 0.8
}
}
const addThematicLayer = (geoJsonData: any, valueProperty: string, min: number, max: number, colors: string[]) => {
if (!map.value) return null
const getColor = createColorScale(min, max, colors)
return L.geoJSON(geoJsonData, {
style: (feature) => {
return applyThematicStyle(feature, getColor, valueProperty)
},
onEachFeature: (feature, layer) => {
layer.on({
mouseover: (e) => {
const layer = e.target
layer.setStyle({
weight: 3,
opacity: 1
})
layer.bringToFront()
},
mouseout: (e) => {
const feature = e.target.feature
e.target.setStyle(applyThematicStyle(feature, getColor, valueProperty))
}
})
}
}).addTo(map.value)
}
const addLegend = (title: string, min: number, max: number, colors: string[], position = 'bottomright') => {
if (!map.value) return null
const legend = L.control({ position: position as L.ControlPosition })
legend.onAdd = () => {
const div = L.DomUtil.create('div', 'info legend')
const grades = []
const step = (max - min) / (colors.length - 1)
for (let i = 0; i < colors.length; i++) {
grades.push(Math.round(min + step * i))
}
div.innerHTML = `<h4>${title}</h4>`
for (let i = 0; i < grades.length; i++) {
div.innerHTML +=
`<i style="background:${colors[i]}"></i> ${grades[i]}${
i < grades.length - 1 ? ' – ' + grades[i + 1] + '<br>' : '+'
}`
}
return div
}
legend.addTo(map.value)
return legend
}
return { addThematicLayer, addLegend, createColorScale }
}
Now we need to create the mapping component src/components/ThematicMapComponent.vue
:
<template>
<div>
<div ref="mapContainer" class="thematic-map"></div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useMap } from '@/composables/useMap'
import { useThematicMap } from '@/composables/useThematicMap'
import { useGeoJson } from '@/composables/useGeoJson'
import type { MapConfig } from '@/types/map'
const mapContainer = ref<HTMLElement | null>(null)
const { map, initializeMap } = useMap(mapContainer)
const { addThematicLayer, addLegend } = useThematicMap(map)
const { loadGeoJson } = useGeoJson(map)
const mapConfig: MapConfig = {
center: [37.8, -96],
zoom: 4,
minZoom: 2,
maxZoom: 10
}
onMounted(async () => {
initializeMap(mapConfig)
// Example: Load US States GeoJSON and create thematic visualization
try {
const response = await fetch('/data/us-states.json')
const statesData = await response.json()
// Population density visualization
addThematicLayer(
statesData,
'density', // property in the GeoJSON to visualize
1, // min value
1000, // max value
['#FFEDA0', '#FED976', '#FEB24C', '#FD8D3C', '#FC4E2A', '#E31A1C', '#BD0026', '#800026'] // color scale
)
// Add a legend
addLegend(
'Population Density',
1,
1000,
['#FFEDA0', '#FED976', '#FEB24C', '#FD8D3C', '#FC4E2A', '#E31A1C', '#BD0026', '#800026'],
'bottomright'
)
} catch (error) {
console.error('Error loading GeoJSON:', error)
}
})
</script>
<style>
.thematic-map {
height: 600px;
width: 100%;
}
.info.legend {
background-color: white;
padding: 8px;
border-radius: 4px;
box-shadow: 0 0 15px rgba(0,0,0,0.2);
line-height: 18px;
color: #555;
}
.legend h4 {
margin: 0 0 5px;
color: #777;
}
.legend i {
width: 18px;
height: 18px;
float: left;
margin-right: 8px;
opacity: 0.7;
}
</style>
Now we need to add the comonent to our src/App.vue
:
<template>
<div class="app-container">
<h1>Thematic Map Example</h1>
<ThematicMapComponent />
</div>
</template>
<script setup lang="ts">
import ThematicMapComponent from './components/ThematicMapComponent.vue'
</script>
Conclusion
In this comprehensive guide, we’ve explored how to integrate LeafletJS with Vue 3, TypeScript, and Vite to create powerful, type-safe mapping applications. We’ve covered:
- Project Setup: Creating a solid foundation with proper configuration and TypeScript support.
- Core Implementation: Building reusable map components using Vue 3’s Composition API with strong TypeScript typing.
- Advanced Features: Adding sophisticated capabilities like custom controls, event optimization, GeoJSON integration, and performance enhancements through clustering.
- Practical Examples: Demonstrating real-world use cases like search functionality and data visualization.
The composable pattern we’ve used throughout this guide provides a clean, maintainable approach to managing map functionality. By extracting reusable logic into composables like useMap
, useMarkers
, and useCluster
, we’ve created a modular architecture that can scale with your application’s needs.