MapLibre React: maps with react-map-gl

react-map-gl wraps MapLibre GL JS in React components: a Map, plus Marker, Popup, Source, and Layer children that update when your state changes. Since version 8 it has its own MapLibre entry point, react-map-gl/maplibre, so it needs neither Mapbox GL JS nor a Mapbox token.

This guide builds a map of coffee shops near a point. Clicking one draws the walking route to it. It uses Mapricot's free map tiles, which need no key, and Mapricot's places and routing APIs for the data. It was tested with react-map-gl 8.1, 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 --version in 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 try MapLibre without React or a build step, start with Add markers and popups to a MapLibre map instead.

1. Create the app

Make a React app with Vite, and add react-map-gl, MapLibre, and a decoder for route lines:

npm create vite@latest my-map -- --template react
cd my-map
npm install
npm install react-map-gl maplibre-gl @mapbox/polyline

If npm create asks whether to install and start now, choose no, since the commands after it do that. react-map-gl/maplibre re-exports the @vis.gl/react-maplibre package, so you can install that instead and import from it. The components are the same.

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 a map

Replace everything in src/App.jsx with:

import Map from "react-map-gl/maplibre";
import { setWorkerUrl } from "maplibre-gl";
import workerUrl from "maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url";
import "maplibre-gl/dist/maplibre-gl.css";

setWorkerUrl(workerUrl);

export default function App() {
  return (
    <Map
      initialViewState={{ longitude: -122.6793, latitude: 45.5189, zoom: 15 }}
      style={{ width: "100%", height: "100vh" }}
      mapStyle="https://tiles.mapricot.com/styles/light.json"
    />
  );
}
  • initialViewState sets where the map starts. After that, people can pan and zoom it freely. To control the view from state instead, pass longitude, latitude, and zoom with an onMove handler.
  • The map needs a height. Here it fills the window.
  • mapStyle takes any MapLibre style URL. Mapricot's map tiles come in light, dark, and grayscale styles, and the style shows the OpenStreetMap credit in the corner.
  • The CSS import styles the controls, markers, and popups.

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. In Vite's dev server, the map 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. The two lines with setWorkerUrl above fix it: Vite's ?worker&url import gives the worker its own URL, and setWorkerUrl hands it to MapLibre. Call setWorkerUrl once, before the first map is created.

3. Add markers and popups

Put a Marker for each place inside the Map. The places are coffee shops near the starting point, from Mapricot's nearby places endpoint.

First, replace the import Map from "react-map-gl/maplibre"; line at the top of src/App.jsx with these two, to bring in React's hooks and the Marker and Popup components:

import { useEffect, useState } from "react";
import Map, { Layer, Marker, NavigationControl, Popup, Source } from "react-map-gl/maplibre";

Layer, NavigationControl, and Source are for later steps. Below the imports and above App, 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([]);
const [selected, setSelected] = useState(null);

useEffect(() => {
  mapricot("/v1/nearby?lat=45.5189&lon=-122.6793&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.

Then, inside the Map, replacing the /> that closes it with > and a closing </Map>, add a marker for each place and a popup for the one that's selected:

{places.map((place) => (
  <Marker
    key={place.id}
    longitude={place.lon}
    latitude={place.lat}
    onClick={(event) => {
      event.originalEvent.stopPropagation();
      setSelected(place);
    }}
  />
))}
{selected && (
  <Popup longitude={selected.lon} latitude={selected.lat} onClose={() => setSelected(null)}>
    {selected.name}
  </Popup>
)}

Ten markers appear around the square. Click one to see its name. The click handler stops the event from reaching the map. Otherwise the map's own click closes the popup as soon as it opens.

The request and its response:

Request
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"
Response
{  "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

A line on the map is a GeoJSON Source with a Layer inside it. When a place is selected, ask Mapricot's routing API for a walking route and turn its polyline into a GeoJSON line. Import the decoder at the top of the file:

import polyline from "@mapbox/polyline";

Add the route's state and effect in App, after the others:

const [route, setRoute] = useState(null);

useEffect(() => {
  if (!selected) return;
  mapricot("/v1/route", {
    waypoints: [{ lat: 45.5189, lon: -122.6793 }, { lat: selected.lat, lon: selected.lon }],
    mode: "walk",
  }).then((data) => {
    const points = polyline.decode(data.routes[0].polyline, 6);
    setRoute({ type: "LineString", coordinates: points.map(([lat, lon]) => [lon, lat]) });
  });
}, [selected]);

Inside the Map, before the markers so the line draws under them, add the route, and a zoom control:

<NavigationControl position="top-right" />
{route && (
  <Source id="route" type="geojson" data={route}>
    <Layer id="route" type="line" paint={{ "line-color": "#fd7e14", "line-width": 5 }} />
  </Source>
)}

Two details catch people out. Mapricot's polylines use six decimal places, so decode with precision 6; most decoders default to Google's 5. And MapLibre takes coordinates as [longitude, latitude], the reverse of the decoder's output.

Click a marker, and 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 Map, { Layer, Marker, NavigationControl, Popup, Source } from "react-map-gl/maplibre";
import { setWorkerUrl } from "maplibre-gl";
import workerUrl from "maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url";
import polyline from "@mapbox/polyline";
import "maplibre-gl/dist/maplibre-gl.css";

setWorkerUrl(workerUrl);

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();
}

export default function App() {
  const [places, setPlaces] = useState([]);
  const [selected, setSelected] = useState(null);
  const [route, setRoute] = useState(null);

  useEffect(() => {
    mapricot("/v1/nearby?lat=45.5189&lon=-122.6793&category=food:coffee_shop&limit=10").then((data) =>
      setPlaces(data.results),
    );
  }, []);

  useEffect(() => {
    if (!selected) return;
    mapricot("/v1/route", {
      waypoints: [{ lat: 45.5189, lon: -122.6793 }, { lat: selected.lat, lon: selected.lon }],
      mode: "walk",
    }).then((data) => {
      const points = polyline.decode(data.routes[0].polyline, 6);
      setRoute({ type: "LineString", coordinates: points.map(([lat, lon]) => [lon, lat]) });
    });
  }, [selected]);

  return (
    <Map
      initialViewState={{ longitude: -122.6793, latitude: 45.5189, zoom: 15 }}
      style={{ width: "100%", height: "100vh" }}
      mapStyle="https://tiles.mapricot.com/styles/light.json"
    >
      <NavigationControl position="top-right" />
      {route && (
        <Source id="route" type="geojson" data={route}>
          <Layer id="route" type="line" paint={{ "line-color": "#fd7e14", "line-width": 5 }} />
        </Source>
      )}
      {places.map((place) => (
        <Marker
          key={place.id}
          longitude={place.lon}
          latitude={place.lat}
          onClick={(event) => {
            event.originalEvent.stopPropagation();
            setSelected(place);
          }}
        />
      ))}
      {selected && (
        <Popup longitude={selected.lon} latitude={selected.lat} onClose={() => setSelected(null)}>
          {selected.name}
        </Popup>
      )}
    </Map>
  );
}

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.

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. To keep the key out of the browser entirely, send these requests through your own backend instead.

Moving from Mapbox

If your app uses react-map-gl with Mapbox GL JS, the switch is small:

  1. Install maplibre-gl and import from react-map-gl/maplibre instead of react-map-gl or react-map-gl/mapbox.
  2. Remove mapboxAccessToken.
  3. Change mapStyle from a mapbox://styles/… URL to a MapLibre style URL, such as Mapricot's.
  4. Import maplibre-gl/dist/maplibre-gl.css instead of Mapbox's CSS.

Mapbox's styles and tiles only work with a Mapbox token, which is why the style URL has to change. Most props and components keep their names. The older react-mapbox-gl package is a different library and hasn't had a release since November 2020, so moving from it means rewriting map code with react-map-gl's components.

Questions

Does react-map-gl work with MapLibre? Yes. Since version 8, import from react-map-gl/maplibre and install maplibre-gl. No Mapbox token is needed.

Why is my MapLibre map blank in React? Usually one of three things: the map has no height, the CSS isn't imported, or, with Vite, MapLibre's worker didn't load. Check the console for Worker failed to load and set the worker URL as shown above.

Are the 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.