Migrate web maps from Google Maps to MapLibre + Protomaps
Host an OpenStreetMap basemap, replace Google Maps JavaScript with MapLibre, and move geocoding. Check data licenses and missing features before switching.
From Google Maps Platform to Protomaps (self-hosted) · Published 2026-10-06
Before you commit
- Hands-on effort
- 1–3 days for a store locator or listings map; more for Places, Street View or routing
- Elapsed time
- Add geocoding reconciliation, a staged rollout and a basemap-refresh schedule.
- Examples cover
- MapLibre GL JS 6 · PMTiles · @protomaps/basemaps 5
- Content review
- 2026-10-09
- Integration testing
- Not recorded. Rehearse the commands and rollback on staging.
Prerequisites
- An API and terms inventory, object storage with Range support, and a frontend staging environment.
- A replacement geocoder and a plan to review stored Google-sourced coordinates and attribution.
Don’t migrate yet if…
- Required Places data, Street View, routing or regional address coverage cannot be replaced.
- Your Google terms prohibit the partial migration you intend to make.
Google Maps on the web was free for most sites until 2018. On 3 May 2018 Google merged its 18 map APIs into Google Maps Platform, required a billing account from 11 June, and switched to pay-as-you-go pricing in July:
| Before 2018 | July 2018 | March 2025 to today | |
|---|---|---|---|
| Free web map loads | 25,000 per day (since 2012) | $200/month credit, about 28,000 loads | 10,000 per month per SKU |
| Dynamic map load, per 1,000 | $0.50 above the free limit | $7.00 | $7.00, falling to $0.53 above 5M/month |
| Geocoding, per 1,000 | 2,500/day free, then $0.50 | $5.00 | $5.00, falling to $0.38 above 5M/month |
MapTiler, a competitor, put the increase at 1,400%: every site with more than about 800 map views a day started paying. In March 2025 Google replaced the $200 credit with free monthly caps per SKU and added volume discounts. That helps very large users. For everyone else the per-load price hasn’t changed since 2018.
At 500,000 map loads and 100,000 geocodes a month, Google costs about $3,000/month on the site’s maps calculator. The setup below serves an OpenStreetMap basemap from a file on Cloudflare R2 for a few dollars in delivery costs, plus storage and a geocoder. Stadia Maps’ Standard plan comes to about $120/month at that volume, or you can self-host Photon. The data and features differ from Google’s, so check your own locations before switching.
0. Before you start
Inventory what you actually call. In Google Cloud Console, open Google Maps Platform → Metrics and Billing → Reports grouped by SKU. Most sites use three things: Dynamic Maps (the JS map), Geocoding, and Places Autocomplete. Anything beyond that needs a decision:
| Google feature | Open replacement | Gap |
|---|---|---|
| Maps JavaScript API (Dynamic Maps) | MapLibre GL JS + Protomaps basemap | None for typical use |
| Geocoding API | Photon, Nominatim or Pelias (self-hosted), or a hosted OSM geocoder | House-number coverage varies by country |
| Places Autocomplete for addresses | Photon (built for type-ahead) or a hosted autocomplete | Business/POI search is much weaker than Google’s |
| Places details (hours, reviews, photos) | None that matches | Keep Google, or drop the feature |
| Directions / Routes | OSRM, Valhalla or GraphHopper | No live traffic |
| Street View | None | Keep Google for that screen, or drop it |
| Mobile Maps SDKs | — | Map display there is free; don’t migrate those for cost |
Read the terms before planning a partial move. Outside the EEA, Google’s Maps Platform Terms §3.2.3(e) forbid using Google’s services “with or near a non-Google Map”, and the Service Specific Terms repeat this for Geocoding (§6.2), Places (§14.2), Directions and Distance Matrix. So you can’t swap only the basemap and keep Google geocoding or Places. They move together. The Places UI Kit (§15.1) is the one exception.
Since 8 July 2025, billing accounts with an EEA address get separate EEA terms without this ban, after proceedings by the German competition authority. EU companies can migrate the basemap first and geocoding later.
Check what Google data you’ve stored. §6.3.1 allows caching Geocoding latitude/longitude for at most 30 consecutive days. The newer §6.3.2 carve-out only covers a per-end-user cache, not a shared database. Coordinates you geocoded with Google and saved on store, listing or customer rows must be re-geocoded with the new provider (step 4). place_id values may be kept (§A.3), but they mean nothing to other providers.
1. Build the basemap file
Protomaps publishes a daily OpenStreetMap build as one PMTiles file. The 6 October 2026 planet build is about 139 GB and covers zoom 0–15. You don’t need to download it: the pmtiles CLI (single binary) cuts a region straight from the remote file.
# Western Europe to zoom 14: lon/lat of the south-west and north-east corners
pmtiles extract https://build.protomaps.com/20261006.pmtiles region.pmtiles \
--bbox=-10.5,35.5,19.5,59.5 --maxzoom=14
Each extra zoom level roughly doubles the size. For a whole-world map, use the planet file as it is. Use --region=area.geojson for a non-rectangular area.
2. Host it
Use storage that supports HTTP Range requests and the CORS settings below. Cloudflare R2 has no egress fees. Reads cost $0.36 per million after 10 million free a month, and storage is billed separately. A map view fetches a few dozen tiles, so delivery for 500,000 views can cost single-digit dollars; panning, zooming and caching change the total.
rclone copy region.pmtiles r2:maps/ # or: pmtiles upload region.pmtiles region.pmtiles --bucket=s3://maps
If the page and the bucket are on different origins, the bucket needs CORS (details):
[{ "AllowedOrigins": ["https://example.com"], "AllowedMethods": ["GET", "HEAD"],
"AllowedHeaders": ["range", "if-match"], "ExposeHeaders": ["etag"], "MaxAgeSeconds": 3000 }]
Put a custom domain and the CDN cache in front of the bucket, so repeat views don’t count as reads.
3. Swap the JavaScript
npm i maplibre-gl pmtiles @protomaps/basemaps
MapLibre GL JS 6 (July 2026) is ESM-only. Older snippets that use <script src="maplibre-gl.js"> or import maplibregl from 'maplibre-gl' no longer work.
import * as maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
import { Protocol } from 'pmtiles';
import { layers, namedFlavor } from '@protomaps/basemaps';
maplibregl.addProtocol('pmtiles', new Protocol().tile); // once per page
const map = new maplibregl.Map({
container: 'map',
center: [-0.1276, 51.5072], // [lng, lat]: the reverse of Google's {lat, lng}
zoom: 11,
style: {
version: 8,
glyphs: 'https://protomaps.github.io/basemaps-assets/fonts/{fontstack}/{range}.pbf',
sprite: 'https://protomaps.github.io/basemaps-assets/sprites/v4/light',
sources: {
protomaps: {
type: 'vector',
url: 'pmtiles://https://maps.example.com/region.pmtiles',
attribution: '<a href="https://protomaps.com">Protomaps</a> © <a href="https://openstreetmap.org/copyright">OpenStreetMap</a>',
},
},
layers: layers('protomaps', namedFlavor('light'), { lang: 'en' }),
},
});
new maplibregl.Marker()
.setLngLat([-0.1276, 51.5072])
.setPopup(new maplibregl.Popup().setText('Head office'))
.addTo(map);
The flavors are light, dark, white, black and grayscale. For production, copy the fonts and sprites to your own bucket too. In React, call addProtocol once in a root effect.
Most Google calls have a direct equivalent:
| Google Maps JS | MapLibre GL JS |
|---|---|
new google.maps.Map(el, { center: {lat, lng}, zoom }) |
new maplibregl.Map({ container: el, center: [lng, lat], zoom, style }) |
Marker / AdvancedMarkerElement |
maplibregl.Marker (pass { element } for custom HTML) |
InfoWindow |
maplibregl.Popup; setText is XSS-safe, setHTML isn’t sanitized |
map.fitBounds(LatLngBounds) |
map.fitBounds([[w, s], [e, n]], { padding }) |
google.maps.event.addListener(map, 'click', …) |
map.on('click', (e) => e.lngLat) |
Polyline / Polygon / map.data.addGeoJson |
a geojson source plus a line or fill layer |
| MarkerClusterer | a geojson source with cluster: true plus circle and symbol layers |
For more than a few hundred markers, use a GeoJSON source and layers instead of Markers. Layers are drawn in WebGL, while each Marker is a separate DOM element, so layers stay fast with thousands of points.
4. Move geocoding
Pick by volume:
- Hosted OSM geocoder. Stadia Maps, Geoapify, LocationIQ, OpenCage and others charge cents to dollars per 1,000 requests, compared with Google’s $5. Read each one’s terms on storing results. Several allow permanent storage, which Google doesn’t.
- Photon self-hosted (Apache-2.0). It’s built for search-as-you-type, so it also replaces address autocomplete. A planet index is about 95 GB on SSD and wants 64 GB RAM. GraphHopper publishes prebuilt planet and country dumps, so there’s no import step. A 64 GB dedicated server costs about $120/month.
- Nominatim self-hosted. It’s the reference OSM geocoder, but a full planet wants 128 GB RAM, 1 TB of NVMe, and a 2.5-day import. The public instance allows at most 1 request per second and forbids autocomplete, so it isn’t a production backend.
Photon’s API returns GeoJSON, with coordinates in the same [lng, lat] order MapLibre uses:
curl 'https://photon.example.com/api/?q=10+Downing+Street+London&limit=1'
Re-geocode stored coordinates in a one-off batch job against the new provider, at a polite rate. Log the rows where the new result is more than ~200 m from the old one. Look at a sample of those by hand before you trust the batch. OSM house-number coverage is excellent in most of Europe and patchier in parts of the US and elsewhere.
5. Attribution
OpenStreetMap data is ODbL. The attribution guidelines require crediting “OpenStreetMap” with a link to openstreetmap.org/copyright, in a corner of the map. The attribution string in step 3 does this through MapLibre’s attribution control. It may collapse into an info button, but it must stay reachable.
6. Roll out and roll back
- Put the new map behind a feature flag and show it to a fraction of traffic. Compare pages side by side at the zoom levels your users actually use.
- Keep the Google API key active during the rollout. Rolling back means flipping the flag.
- Set Google quotas (APIs & Services → Quotas) and a budget alert, so an old page you missed can’t run up a bill.
7. Clean up
- When traffic on the Google SKUs reaches zero, set those quotas to 0 and delete the API key. Keys left in old app versions can still be billed.
- Delete Google-sourced coordinates you no longer need, as §6.3.1 requires.
- Rebuild
region.pmtilesfrom a newer Protomaps build on a schedule. Quarterly is enough for most sites; go monthly if users report missing roads or businesses.
Found a command or version mismatch? Report a correction with the version and steps to reproduce it. Remove secrets and customer data first.