React Leaflet with free vector tiles
React Leaflet puts Leaflet's maps in React components. Leaflet draws image tiles on its own, but not vector tiles. The MapLibre GL Leaflet plugin fills the gap: it adds a Leaflet layer that MapLibre GL JS draws, so any MapLibre style works as a basemap while the rest of your map stays plain Leaflet.
This guide uses Mapricot's free vector tiles as the basemap, which need no key, and adds coffee shops near a point from Mapricot's places API. Clicking one draws the walking route to it. It was tested with react-leaflet 5.0, Leaflet 1.9, MapLibre GL Leaflet 0.1.4, MapLibre GL JS 6.15, React 19, and Vite 8.
Before you start
You need:
- Node.js 20.19 or later, which comes with npm. Run
node --versionin a terminal to check which you have. - A browser key with the Places and Routing products. Getting started shows how to make one. Add
http://localhost:*to its allowed origins, so it works from your computer while you build. The map tiles need no key. - Some familiarity with React components, state, and effects. React's tutorial covers them.
To use Leaflet without React or a build step, see Leaflet geocoding and routing instead.
1. Create the app
Make a React app with Vite, and add React Leaflet, MapLibre, the plugin, and a decoder for route lines:
npm create vite@latest my-map -- --template react
cd my-map
npm install
npm install react-leaflet leaflet maplibre-gl @maplibre/maplibre-gl-leaflet @mapbox/polyline
If npm create asks whether to install and start now, choose no, since the commands after it do that.
Put your key in a file called .env.local in the my-map folder:
VITE_MAPRICOT_KEY=mapricot_pk_…
Vite makes variables that start with VITE_ available to your code as import.meta.env.VITE_MAPRICOT_KEY. Files ending in .local are in the app's .gitignore, so the key stays out of your repository. It still ends up in the page, which is what browser keys are for.
The app starts with some demo styles that would squeeze the map. Replace everything in src/index.css with:
body {
margin: 0;
}
Start the app:
npm run dev
Open http://localhost:5173. It shows Vite's demo page for now, and updates as you save each change below. If you change .env.local, stop the server with Ctrl+C and start it again.
2. Show the map
React Leaflet has no component for the plugin, so write a small one. useMap gives it the Leaflet map, and the effect adds the MapLibre layer and removes it when the component goes away. Replace everything in src/App.jsx with:
import { useEffect } from "react";
import { MapContainer, useMap } from "react-leaflet";
import { maplibreGL } from "@maplibre/maplibre-gl-leaflet";
import { setWorkerUrl } from "maplibre-gl";
import workerUrl from "maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url";
import "leaflet/dist/leaflet.css";
import "maplibre-gl/dist/maplibre-gl.css";
setWorkerUrl(workerUrl);
const start = [45.5189, -122.6793];
function VectorTiles({ style }) {
const map = useMap();
useEffect(() => {
const layer = maplibreGL({ style }).addTo(map);
return () => layer.remove();
}, [map, style]);
return null;
}
export default function App() {
return (
<MapContainer center={start} zoom={15} style={{ height: "100vh" }}>
<VectorTiles style="https://tiles.mapricot.com/styles/light.json" />
</MapContainer>
);
}
startis Pioneer Courthouse Square in Portland, as[latitude, longitude], Leaflet's order.MapContainerneeds a height. Here it fills the window.styletakes any MapLibre style URL. Mapricot's map tiles come inlight,dark, andgrayscalestyles, and the OpenStreetMap credit shows in the map's corner next to Leaflet's.- Import both stylesheets: Leaflet's for the map and its controls, MapLibre's for the layer.
The page now shows a map of downtown Portland. src/App.css isn't used any more, so you can delete it.
Fix "Worker failed to load" in Vite
MapLibre GL JS 6 draws tiles in a web worker. Under Vite's dev server, the basemap stays blank and the console shows Worker failed to load. Check that the worker URL is correct. unless you set the worker's URL yourself, which is what the two setWorkerUrl lines above do. Leaflet's own layers still draw without it, which can make the cause hard to spot.
3. Add markers and popups
React Leaflet's Marker uses Leaflet's default icon images. With Vite they load in development, but a production build can't find them, so the markers turn into broken images. CircleMarker draws with SVG and needs no images. The places are coffee shops near the starting point, from Mapricot's nearby places endpoint.
First, change the first two imports at the top of src/App.jsx to bring in useState and the components for markers, popups, and lines:
import { useEffect, useState } from "react";
import { CircleMarker, MapContainer, Polyline, Popup, useMap } from "react-leaflet";
Below start, add the key and a function that sends a request to Mapricot and returns the JSON response, or throws an error if the API refuses it:
const key = import.meta.env.VITE_MAPRICOT_KEY;
async function mapricot(path, body) {
const response = await fetch(`https://api.mapricot.com${path}`, {
method: body ? "POST" : "GET",
headers: { Authorization: `Bearer ${key}`, ...(body && { "Content-Type": "application/json" }) },
body: body && JSON.stringify(body),
});
if (!response.ok) throw new Error(`Mapricot returned ${response.status}`);
return response.json();
}
At the top of App, before return, keep the places in state and fetch them once when the map first appears:
const [places, setPlaces] = useState([]);
useEffect(() => {
mapricot(`/v1/nearby?lat=${start[0]}&lon=${start[1]}&category=food:coffee_shop&limit=10`).then((data) =>
setPlaces(data.results),
);
}, []);
In development, React's Strict Mode runs each effect twice, so the browser's network tab shows the request twice. A production build makes it once.
Inside the MapContainer, after VectorTiles, draw a circle for each place, with a popup that opens on click:
{places.map((place) => (
<CircleMarker
key={place.id}
center={[place.lat, place.lon]}
radius={8}
pathOptions={{ color: "white", fillColor: "#0d6efd", fillOpacity: 1 }}
eventHandlers={{ click: () => showRoute(place) }}
>
<Popup>{place.name}</Popup>
</CircleMarker>
))}
Clicking a circle opens its popup, and logs an error in the console until the next step adds showRoute. The request and its response:
curl "https://api.mapricot.com/v1/nearby?lat=45.52&lon=-122.68&category=food:coffee_shop&radius=1000&limit=1" \
-H "Authorization: Bearer $MAPRICOT_KEY"{ "attribution": [ { "source": "openstreetmap", "text": "© OpenStreetMap contributors", "url": "https://www.openstreetmap.org/copyright" } ], "results": [ { "id": "mp_b3BlbnN0cmVldG1hcDp2ZW51ZTpub2RlLzYzOTAwOTQ3NTU", "type": "venue", "name": "Public Domain", "label": "Public Domain, Portland, OR, USA", "lat": 45.519965, "lon": -122.679528, "address": { "houseNumber": "603", "street": "SW Broadway", "postalCode": "97205", "locality": "Portland", "region": "Oregon", "regionCode": "OR", "country": "United States", "countryCode": "US" }, "categories": [ "food", "food:coffee_shop" ], "distanceMeters": 37, "source": "openstreetmap" } ]}4. Draw a route
Ask Mapricot's routing API for a walking route when a place is clicked, and draw it with Leaflet's Polyline. Import the decoder at the top of the file:
import polyline from "@mapbox/polyline";
Add the route's state and a function to fetch it in App, after the effect:
const [route, setRoute] = useState(null);
async function showRoute(place) {
const data = await mapricot("/v1/route", {
waypoints: [{ lat: start[0], lon: start[1] }, { lat: place.lat, lon: place.lon }],
mode: "walk",
});
setRoute(polyline.decode(data.routes[0].polyline, 6));
}
Inside the MapContainer, after VectorTiles and before the circles so the line draws under them, add the line:
{route && <Polyline positions={route} pathOptions={{ color: "#fd7e14", weight: 5 }} />}
Mapricot's polylines use six decimal places, so decode with precision 6; most decoders default to Google's 5. The decoder returns [latitude, longitude] pairs, which is the order Leaflet wants, so they go straight to Polyline.
Click a circle, and its popup opens as the walking route to it draws in orange.
The whole component
Here's src/App.jsx with all of the above:
import { useEffect, useState } from "react";
import { CircleMarker, MapContainer, Polyline, Popup, useMap } from "react-leaflet";
import { maplibreGL } from "@maplibre/maplibre-gl-leaflet";
import { setWorkerUrl } from "maplibre-gl";
import workerUrl from "maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url";
import polyline from "@mapbox/polyline";
import "leaflet/dist/leaflet.css";
import "maplibre-gl/dist/maplibre-gl.css";
setWorkerUrl(workerUrl);
const start = [45.5189, -122.6793];
const key = import.meta.env.VITE_MAPRICOT_KEY;
async function mapricot(path, body) {
const response = await fetch(`https://api.mapricot.com${path}`, {
method: body ? "POST" : "GET",
headers: { Authorization: `Bearer ${key}`, ...(body && { "Content-Type": "application/json" }) },
body: body && JSON.stringify(body),
});
if (!response.ok) throw new Error(`Mapricot returned ${response.status}`);
return response.json();
}
function VectorTiles({ style }) {
const map = useMap();
useEffect(() => {
const layer = maplibreGL({ style }).addTo(map);
return () => layer.remove();
}, [map, style]);
return null;
}
export default function App() {
const [places, setPlaces] = useState([]);
const [route, setRoute] = useState(null);
useEffect(() => {
mapricot(`/v1/nearby?lat=${start[0]}&lon=${start[1]}&category=food:coffee_shop&limit=10`).then((data) =>
setPlaces(data.results),
);
}, []);
async function showRoute(place) {
const data = await mapricot("/v1/route", {
waypoints: [{ lat: start[0], lon: start[1] }, { lat: place.lat, lon: place.lon }],
mode: "walk",
});
setRoute(polyline.decode(data.routes[0].polyline, 6));
}
return (
<MapContainer center={start} zoom={15} style={{ height: "100vh" }}>
<VectorTiles style="https://tiles.mapricot.com/styles/light.json" />
{route && <Polyline positions={route} pathOptions={{ color: "#fd7e14", weight: 5 }} />}
{places.map((place) => (
<CircleMarker
key={place.id}
center={[place.lat, place.lon]}
radius={8}
pathOptions={{ color: "white", fillColor: "#0d6efd", fillOpacity: 1 }}
eventHandlers={{ click: () => showRoute(place) }}
>
<Popup>{place.name}</Popup>
</CircleMarker>
))}
</MapContainer>
);
}
To put the app online, run npm run build. It writes the site to the dist folder, ready for any static host. Add your site's address to the key's allowed origins first.
What the plugin can't do
The MapLibre layer follows Leaflet, so a few MapLibre features aren't available:
- No rotation or tilt. Leaflet maps stay flat and north-up.
- Slower panning. Leaflet handles the mouse and touch input and asks MapLibre to redraw to match, which can lag slightly behind Leaflet's own layers.
- Latitude limits. MapLibre stops closer to the poles than Leaflet. The plugin suggests setting
maxBounds={[[180, -Infinity], [-180, Infinity]]}andmaxBoundsViscosity={1}onMapContainerso people can't pan past them, andminZoom={1}.
If you're starting fresh and don't need Leaflet's plugins, MapLibre with react-map-gl draws vector tiles directly, with rotation and smoother panning.
Keys in the browser
The map tiles need no key. The places and routing requests do, and anyone who opens the page can read a key that's in it. Create a separate key with only the places and routing scopes for this page, and keep keys with wider access on your servers. Security explains how keys are protected.
Questions
Can Leaflet show vector tiles? Not on its own. Leaflet draws image tiles. The MapLibre GL Leaflet plugin adds a layer that draws vector tiles with MapLibre GL JS, as shown above.
Are these map tiles free? Mapricot's tiles are free, need no key, and don't count against your requests. Places and routing requests come from 250,000 free requests every 30 days, then cost $0.10 per 1,000.
Why are my React Leaflet marker icons broken? Leaflet works out where its default icon images are from its stylesheet's path, which a bundled production build changes. Use CircleMarker, or import the icon images and pass them to L.Icon yourself.