SV Premium

Migration guides

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:

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

  1. 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.
  2. Keep the Google API key active during the rollout. Rolling back means flipping the flag.
  3. 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

Found a command or version mismatch? Report a correction with the version and steps to reproduce it. Remove secrets and customer data first.