React Leaflet Tutorial: Maps with react-leaflet 5 | Geoapify

A Vite + React 19 project with Geoapify map tiles, custom PNG markers from the Marker Icon API and a walking route from the Routing API

React + Leaflet banner: react-leaflet 5 code next to a browser map of Munich with two flag markers and a walking route
React + Leaflet banner: react-leaflet 5 code next to a browser map of Munich with two flag markers and a walking route

To add a Leaflet map to a React app, install leaflet and react-leaflet, render a MapContainer with a TileLayer, and give the map container a height; markers, popups and GeoJSON layers are then React components inside it. In this tutorial you build a React 19 app with react-leaflet 5 in four steps:

Start with a fresh Vite project using its React template. The examples below use Node 24, Vite 8.3, React 19.3, react-leaflet 5.0.0 and Leaflet 1.9.4.

What Do You Need Before You Start?

You need three things: a recent Node.js, a Geoapify API key and a map style.

  • Node.js 20.19+ or 22.12+. Vite 8 does not run on older versions. Check with node -v.
  • A Geoapify API key. Create a project in Geoapify MyProjects and copy its key. The Free plan includes 3,000 credits per day and needs no credit card; see pricing.
  • A map style. This tutorial uses osm-bright. Other styles, such as osm-carto, positron or dark-matter, are listed in the Map Tiles documentation.

Step 1. How Do You Create the React Project?

Create a project with the official Vite React template, then install Leaflet and react-leaflet:

npm create vite@latest my-map -- --template react
cd my-map
npm install
npm i leaflet react-leaflet

Create .env.local in the project root and add VITE_GEOAPIFY_API_KEY=YOUR_API_KEY, replacing the placeholder with your key. Vite exposes variables with the VITE_ prefix to browser code as import.meta.env.VITE_GEOAPIFY_API_KEY. Create a src/components/ directory for the map component and stylesheet in Step 2.

react-leaflet 5 declares react ^19, react-dom ^19 and leaflet ^1.9 as peer dependencies. If your app is still on React 18, use react-leaflet 4 (npm i react-leaflet@4) instead.

Step 2. How Do You Show a Map with react-leaflet?

A react-leaflet map is a MapContainer (the Leaflet map itself) with at least one layer inside it. Here the layer is a TileLayer that loads Geoapify raster tiles. Create src/components/my-map.jsx with:

import { MapContainer, TileLayer } from 'react-leaflet';
import 'leaflet/dist/leaflet.css';
import './my-map.css';

const apiKey = import.meta.env.VITE_GEOAPIFY_API_KEY;
const mapStyle = 'osm-bright';
const retina = window.devicePixelRatio > 1 ? '@2x' : '';

export default function MyMap() {
  return (
    <MapContainer center={[49, 11]} zoom={4} className="map-container">
      <TileLayer
        url={`https://maps.geoapify.com/v1/tile/${mapStyle}/{z}/{x}/{y}${retina}.png?apiKey=${apiKey}`}
        attribution='Powered by <a href="https://www.geoapify.com/" target="_blank">Geoapify</a> | © <a href="https://www.openstreetmap.org/copyright" target="_blank">OpenStreetMap</a> contributors'
        maxZoom={20}
      />
    </MapContainer>
  );
}

Give the map an explicit height in src/components/my-map.css:

.map-container {
  width: 100%;
  height: 70vh;
  min-height: 400px;
}

Replace Vite's starter src/index.css so its centered page layout does not constrain the map:

body {
  margin: 0;
}

Replace src/App.jsx so the Vite app renders the map component:

import MyMap from './components/my-map';

export default function App() {
  return <MyMap />;
}

Run npm run dev and open the local URL Vite prints, usually http://localhost:5173/. A map of Central Europe appears. What each part does:

  • leaflet/dist/leaflet.css positions tiles and controls. Without it, tiles appear scattered or the map stays grey.
  • The tile URL follows the pattern https://maps.geoapify.com/v1/tile/{style}/{z}/{x}/{y}.png. Adding @2x before .png requests double-resolution tiles, so on retina screens the labels stay sharp.
  • attribution shows "Powered by Geoapify" and the OpenStreetMap credit in the corner. The Free plan requires this attribution.
  • center is [latitude, longitude], the order Leaflet uses everywhere.

MapContainer props are set once

MapContainer reads center, zoom and its other props only when it mounts. Changing center in state later does not move the map. To change the view after mount, use the Leaflet map instance from the useMap() hook in a child component, for example map.setView(...) or map.flyTo(...). The vector layer in the optional section below uses the same hook.

Step 3. How Do You Add Custom Markers?

A react-leaflet Marker takes a position and an optional icon. The default Leaflet icon is a blue pin with a separate shadow image; for a branded marker, use a PNG from the Marker Icon API and wrap it in L.icon.

The Marker Icon API request

The icon is a plain image URL, so the browser loads it like any <img>. This REST request returns a blue pin with a white flag icon:

https://api.geoapify.com/v2/icon/?type=awesome&color=%233e9cfe&size=60&icon=flag&contentSize=24&contentColor=%23ffffff&noWhiteCircle&scaleFactor=2&apiKey=YOUR_API_KEY

Colors are URL-encoded (%23 is #). icon=flag is a Font Awesome icon name, and scaleFactor=2 returns the image at double resolution for retina screens. The add map markers how-to lists the other marker types and parameters.

Why the icon is 46 × 67 with anchor [23, 60]

With size=60, the marker is displayed at 46 × 67 px: 60 px of pin plus 7 px of built-in shadow below it. With scaleFactor=2, the PNG itself is 92 × 134 px, and Leaflet scales it down to the iconSize you set. These are the marker definition and the two markers from the tested component:

const start = [48.13695, 11.57603]; // Marienplatz, Munich [lat, lon]
const end = [48.14567, 11.56569];  // Königsplatz

const markerIcon = L.icon({
  iconUrl: `https://api.geoapify.com/v2/icon/?type=awesome&color=%233e9cfe&size=60&icon=flag&contentSize=24&contentColor=%23ffffff&noWhiteCircle&scaleFactor=2&apiKey=${apiKey}`,
  iconSize: [46, 67],
  iconAnchor: [23, 60],
  popupAnchor: [0, -62]
});

// inside <MapContainer>:
<Marker position={start} icon={markerIcon}><Popup>Marienplatz</Popup></Marker>
<Marker position={end} icon={markerIcon}><Popup>Königsplatz</Popup></Marker>

The three numbers matter more than the URL:

OptionValueWhy
iconSize[46, 67]The displayed size; the 2× image is scaled down to it.
iconAnchor[23, 60]Horizontal center (46 / 2) and the pin tip at 60 px; the shadow below the tip does not count.
popupAnchor[0, -62]Relative to the anchor: opens the popup just above the top of the pin.

If the anchor is wrong, the marker looks fine but points a few meters off, which shows when you zoom in. The coordinates of Marienplatz and Königsplatz come from the Geoapify Geocoding API. L is imported from leaflet; the complete file is in Step 4.

Step 4. How Do You Draw a Route on the Map?

The Routing API returns a route as GeoJSON, and react-leaflet's <GeoJSON> component draws GeoJSON directly. So drawing a route takes one fetch and one component.

The Routing API request and response

The route request lists waypoints as lat,lon pairs separated by |, plus a travel mode. This REST request asks for a walking route from Marienplatz to Königsplatz:

https://api.geoapify.com/v1/routing?waypoints=48.13695,11.57603|48.14567,11.56569&mode=walk&apiKey=YOUR_API_KEY

By default the response is a GeoJSON FeatureCollection. Simplified response (legs and most of the 109 coordinate pairs removed):

{
  "type": "FeatureCollection",
  "features": [
    {
      "type": "Feature",
      "properties": {
        "mode": "walk",
        "distance": 1588,
        "distance_units": "meters",
        "time": 1446.992
      },
      "geometry": {
        "type": "MultiLineString",
        "coordinates": [
          [[11.576033, 48.136954], [11.575853, 48.137003], "..."]
        ]
      }
    }
  ]
}

The walk is 1,588 m and about 24 minutes (time is in seconds). Note the coordinate order: waypoints takes lat,lon, while GeoJSON coordinates are [lon, lat]. <GeoJSON> handles the GeoJSON order for you; only swap values when you read coordinates yourself, for example to place a marker. All parameters are in the Routing API documentation.

The complete component

The final src/components/my-map.jsx combines the map, the markers and the route:

import { useEffect, useState } from 'react';
import { MapContainer, TileLayer, Marker, Popup, GeoJSON } from 'react-leaflet';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import './my-map.css';

const apiKey = import.meta.env.VITE_GEOAPIFY_API_KEY;
const retina = window.devicePixelRatio > 1 ? '@2x' : '';

const start = [48.13695, 11.57603]; // Marienplatz, Munich [lat, lon]
const end = [48.14567, 11.56569];  // Königsplatz

const markerIcon = L.icon({
  iconUrl: `https://api.geoapify.com/v2/icon/?type=awesome&color=%233e9cfe&size=60&icon=flag&contentSize=24&contentColor=%23ffffff&noWhiteCircle&scaleFactor=2&apiKey=${apiKey}`,
  iconSize: [46, 67],
  iconAnchor: [23, 60],
  popupAnchor: [0, -62]
});

export default function MyMap() {
  const [route, setRoute] = useState(null);
  const [routeError, setRouteError] = useState('');

  useEffect(() => {
    const controller = new AbortController();
    fetch(`https://api.geoapify.com/v1/routing?waypoints=${start.join(',')}|${end.join(',')}&mode=walk&apiKey=${apiKey}`, { signal: controller.signal })
      .then(async (res) => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        const data = await res.json();
        if (data?.type !== 'FeatureCollection' || !Array.isArray(data.features) || data.features.length === 0) {
          throw new Error('No route was returned');
        }
        return data;
      })
      .then(setRoute)
      .catch((error) => {
        if (error.name !== 'AbortError') setRouteError(error.message);
      });
    return () => controller.abort();
  }, []);

  return (
    <>
      {routeError && <p role="alert">Could not load the route: {routeError}. Check your API key and waypoints.</p>}
      <MapContainer center={[48.1413, 11.5709]} zoom={15} className="map-container">
        <TileLayer
          url={`https://maps.geoapify.com/v1/tile/osm-bright/{z}/{x}/{y}${retina}.png?apiKey=${apiKey}`}
          attribution='Powered by <a href="https://www.geoapify.com/" target="_blank">Geoapify</a> | © <a href="https://www.openstreetmap.org/copyright" target="_blank">OpenStreetMap</a> contributors'
          maxZoom={20}
        />
        <Marker position={start} icon={markerIcon}><Popup>Marienplatz</Popup></Marker>
        <Marker position={end} icon={markerIcon}><Popup>Königsplatz</Popup></Marker>
        {route && <GeoJSON data={route} style={{ color: '#3e9cfe', weight: 5, opacity: 0.8 }} />}
      </MapContainer>
    </>
  );
}

Two details make it work in React:

  • AbortController. In dev mode, StrictMode mounts the component, unmounts it and mounts it again. The cleanup function aborts the first request, so only the active request sets state. Aborts are ignored; HTTP errors and unexpected responses show a message above the map.
  • {route && <GeoJSON … />}. The layer is rendered only once the data has arrived. <GeoJSON> reads data when it is created, which is another reason to render it after the fetch rather than with an empty object first.
React Leaflet map of central Munich with two custom flag markers from the Marker Icon API and a walking route from Marienplatz to Königsplatz

Can Leaflet Show Vector Maps? (Optional)

Leaflet renders raster tiles only, but the @maplibre/maplibre-gl-leaflet plugin adds a MapLibre GL layer that draws Geoapify vector tiles from a style.json. Install both packages:

npm i maplibre-gl @maplibre/maplibre-gl-leaflet

We tested this with maplibre-gl 6.16 and @maplibre/maplibre-gl-leaflet 0.1.4 (its peer dependency allows maplibre-gl up to version 6). The layer is added through useMap(), because react-leaflet has no component for it:

import { useEffect } from 'react';
import { MapContainer, useMap } from 'react-leaflet';
import L from 'leaflet';
import * as maplibregl from 'maplibre-gl';
import workerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?url';
import '@maplibre/maplibre-gl-leaflet';
import 'leaflet/dist/leaflet.css';
import 'maplibre-gl/dist/maplibre-gl.css';
import './my-map.css';
maplibregl.setWorkerUrl(workerUrl);
window.maplibregl = maplibregl;
const apiKey = import.meta.env.VITE_GEOAPIFY_API_KEY;
function VectorLayer() {
  const map = useMap();
  useEffect(() => {
    const layer = L.maplibreGL({ style: `https://maps.geoapify.com/v1/styles/osm-bright/style.json?apiKey=${apiKey}` }).addTo(map);
    return () => { map.removeLayer(layer); };
  }, [map]);
  return null;
}
export default function MyMap() {
  return <MapContainer center={[48.142, 11.568]} zoom={14} className="map-container"><VectorLayer /></MapContainer>;
}

Two lines are easy to miss:

  • window.maplibregl = maplibregl. The plugin looks for MapLibre on the global window, not in the ES module graph.
  • setWorkerUrl(workerUrl). MapLibre parses tiles in a web worker; the ?url import lets Vite bundle the worker file and pass its URL.

If most of your map is vector and you need rotation, tilt or smooth zoom, consider MapLibre GL on its own instead of inside Leaflet; the Geoapify React samples include a MapLibre GL version. Our Leaflet vs MapLibre GL vs OpenLayers comparison covers the trade-offs.

How Do You Build the App for Production?

Run the Vite build from the project folder:

npm run build

Vite writes the static files to dist/; deploy that folder to any static host. The API key from .env.local is inlined into the JavaScript bundle at build time, so anyone can read it in the browser. That is normal for map keys: protect it by setting allowed HTTP referrers or origins for the key in MyProjects.

Should You Use react-leaflet or Plain Leaflet?

Both work with React 19. react-leaflet wraps Leaflet in components and manages the map's lifecycle; plain Leaflet gives you the map object directly, and you manage the lifecycle yourself:

Criterionreact-leafletPlain Leaflet in React
Packagesleaflet + react-leafletleaflet only
How you add layersJSX components: <TileLayer>, <Marker>, <GeoJSON>Imperative calls: L.tileLayer(...).addTo(map)
Map creation and cleanupHandled by MapContaineruseRef + useEffect + map.remove() in the cleanup
StrictMode double mountHandledThrows "Map container is already initialized" without cleanup
Access to the Leaflet mapuseMap() in a child componentThe map variable in your effect
Leaflet plugins without componentsThrough useMap()Direct
React versionv5: React 19; v4: React 18Any

The same map as Step 2, in plain Leaflet:

import { useEffect, useRef } from 'react';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import './my-map.css';

const apiKey = import.meta.env.VITE_GEOAPIFY_API_KEY;
const mapStyle = 'osm-bright';

export default function MyMap() {
  const mapContainer = useRef(null);

  useEffect(() => {
    const map = L.map(mapContainer.current).setView([49, 11], 4); // [latitude, longitude]
    const retina = L.Browser.retina ? '@2x' : '';

    L.tileLayer(`https://maps.geoapify.com/v1/tile/${mapStyle}/{z}/{x}/{y}${retina}.png?apiKey=${apiKey}`, {
      attribution: 'Powered by <a href="https://www.geoapify.com/" target="_blank">Geoapify</a> | © <a href="https://www.openstreetmap.org/copyright" target="_blank">OpenStreetMap</a> contributors',
      maxZoom: 20
    }).addTo(map);

    // remove the map when the component unmounts (and on React StrictMode's second mount)
    return () => map.remove();
  }, []);

  return <div ref={mapContainer} className="map-container" />;
}

Choose react-leaflet when map content follows React state: markers from a list, a route that changes with user input. Choose plain Leaflet when the map is mostly static, when you port existing Leaflet code, or when you rely on plugins with no react-leaflet wrapper. This version is also the "Leaflet" sample in the Geoapify React samples. Working with Angular instead? See the Angular + Leaflet tutorial.

Frequently Asked Questions

Which react-leaflet version should I use with React 18 or React 19?

react-leaflet 5 requires React 19 (its peer dependencies are react ^19, react-dom ^19 and leaflet ^1.9). For React 18, install react-leaflet 4. Peer dependencies checked on npm on 2026-10-26; see the react-leaflet documentation.

Why is my react-leaflet map grey or empty?

Usually one of two things: the map container has no height (give .map-container an explicit height or a parent with one), or leaflet/dist/leaflet.css is not imported. Also check that the tile URL contains a valid key; a key from MyProjects works with the Map Tiles URLs.

How do I fix "Map container is already initialized" in React?

The error comes from plain Leaflet: React StrictMode mounts the component twice, and L.map() is called on the same element again. Return () => map.remove() from your useEffect. react-leaflet's MapContainer handles this for you. See the Geoapify React samples.

Can Leaflet show vector tiles?

Not by itself; Leaflet renders raster tiles. The @maplibre/maplibre-gl-leaflet plugin adds a MapLibre GL layer that renders a vector style.json, such as Geoapify's osm-bright style. In a Vite app, set window.maplibregl and call setWorkerUrl first.

How do I keep markers and tiles sharp on retina screens?

Request double-resolution images: add @2x to the tile URL before .png, and add scaleFactor=2 to the Marker Icon API URL while keeping iconSize at the displayed size (46 × 67 px for size=60).

What's Next?

Your React map now shows tiles, custom markers and a route. From here:

World map

Map Tiles by Geoapify

Raster and vector map tiles in many styles, ready for Leaflet and react-leaflet

Add markers, routes, address search and places to the same map with Geoapify APIs, starting on the Free plan.