Migrate from Google Maps to MapLibre: a step-by-step guide
BSD-licensed renderer, no API key required, open style spec. Concrete before/after code for Map, Marker, InfoWindow, and geocoding — plus the gotchas that bit us in production.
If you shipped a Google Maps integration in 2020, the google.maps.Map constructor and the InfoWindow class feel like furniture. Migrating to MapLibre feels like throwing out the furniture. This guide walks through the migration with concrete before/after code, then flags the gotchas that bite teams in production.
We migrated our own products from Google Maps to MapLibre + maps.guru tile serving in 2024. The migration took 2 engineers × 6 weeks for ~80 map touch-points across our codebase. This post condenses what we learned.
Why MapLibre over staying on Google
Three reasons that drive the migration:
- No API key required for the renderer. MapLibre GL JS is BSD-3-Clause licensed. The library ships open, you don't need to register for a Google Cloud account to embed a map.
- No per-map-load billing. MapLibre itself is free. You only pay for the tile server you point it at. Switching tile vendors becomes a one-line config change rather than a billing migration.
- Open style spec. Mapbox Style Spec is documented, version-controlled, and not subject to vendor lock-in. Google's
MapTypeStyleinterface is closed.
The trade-off: MapLibre GL JS is a lower-level library. You write more code for things Google gives you for free (Street View, traffic, Places autocomplete). For pure tile rendering with custom data overlays, MapLibre wins on cost and flexibility.
Install
npm install maplibre-gl
# or pnpm add maplibre-gl
That's it. No Google Cloud account, no billing setup, no API key registration.
Migration step 1 — replace the Map constructor
Before (Google Maps)
<script src="https://maps.googleapis.com/maps/api/js?key=YOUR_GOOGLE_KEY&libraries=places"></script>
<div id="map" style="height: 400px;"></div>
<script>
const map = new google.maps.Map(document.getElementById('map'), {
center: { lat: 12.9716, lng: 77.5946 },
zoom: 12,
mapId: 'YOUR_MAP_ID',
});
</script>
After (MapLibre)
import maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
const map = new maplibregl.Map({
container: 'map',
style:
'https://tiles.maps.guru/styles/basic/style.json?key=YOUR_MAPSGURU_KEY',
center: [77.5946, 12.9716], // [lng, lat] — note the order swap
zoom: 12,
});
Two visible differences:
styleis a URL to a JSON file. Maps.guru (and Mapbox, Stadia, MapTiler) all serve MapLibre-compatible styles. The style URL includes your API key as a query param for usage tracking. NomapIdregistration needed.- Coordinates are
[lng, lat], not{lat, lng}. GeoJSON convention. This trips everyone up the first time.
Migration step 2 — replace Markers
Before (Google Maps)
const marker = new google.maps.Marker({
position: { lat: 12.9716, lng: 77.5946 },
map,
title: 'Bangalore',
});
marker.addListener('click', () => {
console.log('Marker clicked');
});
After (MapLibre)
const marker = new maplibregl.Marker({ color: '#FF7F11' })
.setLngLat([77.5946, 12.9716])
.setPopup(new maplibregl.Popup().setHTML('<h3>Bangalore</h3>'))
.addTo(map);
marker.getElement().addEventListener('click', () => {
console.log('Marker clicked');
});
MapLibre Markers are DOM elements by default — they're real <div> nodes you can style with CSS. For high-performance applications with thousands of markers, switch to a symbol layer in your style spec instead.
Migration step 3 — replace InfoWindow with Popup
Before (Google Maps)
const infoWindow = new google.maps.InfoWindow({
content: '<h3>Bangalore</h3><p>Capital of Karnataka</p>',
});
marker.addListener('click', () => {
infoWindow.open(map, marker);
});
After (MapLibre)
import maplibregl from 'maplibre-gl';
const popup = new maplibregl.Popup({ offset: 25 }).setHTML(
'<h3>Bangalore</h3><p>Capital of Karnataka</p>',
);
const marker = new maplibregl.Marker()
.setLngLat([77.5946, 12.9716])
.setPopup(popup)
.addTo(map);
.setPopup() on a Marker wires the click → open automatically. To open a popup without a marker, call popup.setLngLat([lng, lat]).addTo(map).
Migration step 4 — replace Geocoding
Google Maps geocoding requires the Places library. With MapLibre, you point at any vendor's geocoding endpoint — we recommend using a vendor that exposes a Mapbox-compatible geocoding API.
Before (Google Maps)
const geocoder = new google.maps.Geocoder();
geocoder.geocode({ address: 'Bangalore, India' }, (results, status) => {
if (status === 'OK') {
const { lat, lng } = results[0].geometry.location;
map.setCenter({ lat: lat(), lng: lng() });
}
});
After (MapLibre + maps.guru geocoding)
const response = await fetch(
`https://api.maps.guru/v1/geocode/search?text=Bangalore,+India&key=${API_KEY}`,
);
const { features } = await response.json();
if (features?.length) {
const [lng, lat] = features[0].geometry.coordinates;
map.setCenter([lng, lat]);
}
The response is GeoJSON FeatureCollection. Coordinates in [lng, lat] order — same as the MapLibre Map center.
Migration step 5 — replace Places Autocomplete (optional)
Google's Places Autocomplete has no direct MapLibre equivalent. You have three options:
- Build your own debounced text input that calls any Mapbox-compatible geocoding API. ~80 lines of Vue or React.
- Use Mapbox Geocoding's autocomplete endpoint (works with MapLibre).
- Drop autocomplete if your UX doesn't strictly need it. Many products ship fine with a single search box that geocodes on submit.
For most migrations, we recommend option 1. Here's the minimum pattern:
<script setup lang="ts">
import maplibregl from 'maplibre-gl';
import { ref } from 'vue';
const query = ref('');
const suggestions = ref<Array<{ name: string; lng: number; lat: number }>>(
[],
);
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
async function search(q: string) {
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(async () => {
const response = await fetch(
`https://api.maps.guru/v1/geocode/search?text=${encodeURIComponent(q)}&key=${API_KEY}`,
);
const { features } = await response.json();
suggestions.value = features.map((f: any) => ({
name: f.properties.name,
lng: f.geometry.coordinates[0],
lat: f.geometry.coordinates[1],
}));
}, 250);
}
function select(s: { name: string; lng: number; lat: number }) {
map.setCenter([s.lng, s.lat]);
suggestions.value = [];
query.value = s.name;
}
</script>
<template>
<div class="relative">
<input
v-model="query"
type="text"
placeholder="Search a place"
class="w-full rounded-md border border-border bg-card px-4 py-2"
@input="search(query)"
/>
<ul
v-if="suggestions.length"
class="absolute z-10 mt-1 w-full rounded-md border border-border bg-card shadow-none"
>
<li
v-for="s in suggestions"
:key="s.name"
class="cursor-pointer px-4 py-2 hover:bg-muted"
@click="select(s)"
>
{{ s.name }}
</li>
</ul>
</div>
</template>
That's 90% of what Google Places Autocomplete does, without the Google dependency.
Gotchas that bite in production
1. Coordinate order swaps everywhere
Google: {lat, lng} objects. MapLibre: [lng, lat] arrays. Anywhere you build a coordinate, double-check the order. The error message is usually "Invalid LngLat" or a tile that loads but renders in the wrong hemisphere.
2. Event names differ
| Google Maps event | MapLibre event |
|---|---|
click | click (same) |
center_changed | move |
zoom_changed | zoom |
bounds_changed | moveend |
tilesloaded | idle |
dragend | moveend |
The semantic rename from center_changed (continuous) to move (continuous) and bounds_changed (continuous) to moveend (discrete, fires after the move completes) changes how you debounce expensive operations.
3. Projection differences
Google Maps uses the Web Mercator projection with full Earth coverage. MapLibre also uses Web Mercator but defaults to a different rendering scale at low zooms. If your app relies on a specific visual treatment at zoom level 2 or lower, render screenshots before declaring parity.
4. Touch gesture conflicts
MapLibre defaults to more aggressive touch panning than Google Maps. On mobile, single-finger pans can compete with your app's scroll handler. Add cooperativeGestures: true to the Map constructor for the same UX Google provides by default.
5. SVG vs canvas rendering
Google Maps renders to canvas only. MapLibre supports both. If you need crisp SVG output for print exports, set map.getCanvas().toDataURL('image/png') — but ensure all your layers are canvas-compatible. Symbol layers with custom SVG images need to be loaded before the export call.
6. CORS on tile servers
MapLibre fetches tiles directly from your style URL's source server. The tile server must return Access-Control-Allow-Origin: * headers. maps.guru tiles do this by default. If you self-host tiles, configure your CDN accordingly.
7. Memory leaks from removed layers
Google Maps handles cleanup internally. MapLibre requires you to call map.removeLayer(id) and map.removeSource(id) before re-adding. Forgetting causes memory leaks that surface as "Slow map" warnings after ~30 minutes of interaction.
React / Vue wrappers
If you're in React, react-map-gl wraps MapLibre in idiomatic React components. For Vue, vue-maplibre-gl does the same.
For framework-specific tutorials, see MapLibre with React and MapLibre with Vue.
When NOT to migrate
Three scenarios where staying on Google Maps is the right call:
- You depend on Google Places POI quality at scale. Google's POI dataset is still the deepest. For local search, hours of operation, reviews, the migration cost outweighs the savings.
- You need Street View. No MapLibre-native equivalent. You'd need to integrate a separate Street View library, increasing bundle size.
- You're on Google Maps for iOS / Android native SDKs. MapLibre's native SDKs exist but the feature parity isn't there yet. The migration is real engineering work, not a config swap.
For the rest of us — anyone rendering tiles with markers, polygons, and custom data overlays — the migration pays for itself in 3–6 months at any meaningful volume.
FAQ
Is MapLibre GL JS free?
Yes, the renderer library is BSD-3-Clause licensed. You only pay for the tile server you point it at. For free tiles, MapTiler offers a 100k requests/month free tier (non-commercial, logo required); maps.guru offers 50k tiles/month free for commercial use.
Can I keep using my Google Maps style?
Partially. MapLibre uses the Mapbox Style Spec, which is similar to Google's MapTypeStyle but not identical. Custom styling needs to be rewritten in JSON style spec format. Most teams re-style from scratch in the new format — it takes a day, not a week.
Does MapLibre work offline?
Yes, with caveats. The library can render tiles from any source, including local file paths or offline tile archives. Bundle size for fully offline operation is ~5–15 MB depending on region coverage.
What about marker performance with thousands of points?
MapLibre's default Marker class adds a DOM node per marker — fine for ~100, but slow past ~1,000. For high-density point visualization, use a GeoJSON source with a circle or symbol layer:
map.addSource('points', {
type: 'geojson',
data: '/api/points.geojson',
});
map.addLayer({
id: 'points-layer',
type: 'circle',
source: 'points',
paint: {
'circle-radius': 6,
'circle-color': '#FF7F11',
},
});
This renders tens of thousands of points smoothly because it's GPU-rendered.
How do I handle Google Maps WebGL context loss?
MapLibre fires a webglcontextlost event on the Map object. Listen for it and call map.triggerRepaint() after webglcontextrestored. Most production apps add this defensively:
map.getCanvas().addEventListener('webglcontextlost', () => {
console.warn('WebGL context lost, will restore');
});
map.getCanvas().addEventListener('webglcontextrestored', () => {
map.triggerRepaint();
});