Type something to search...
Integrating Maps into React Apps with Leaflet

Integrating Maps into React Apps with Leaflet

Store locators, delivery tracking, property listings, event pages: sooner or later most apps need a map. The obvious choice, Google Maps, comes with API keys, billing accounts, and usage quotas. For a lot of projects that's more than you need.

Leaflet is a small, open-source map library that has been the default for web maps for over a decade. It works with free tile providers like OpenStreetMap, handles panning, zooming, markers, and shapes, and has a huge plugin ecosystem. The react-leaflet package wraps it in React components, so markers and layers become JSX you can render from state.

This post walks through adding Leaflet to a React 19 app: installing and fixing the classic CSS and marker icon issues, rendering markers from data, controlling the map from code, reacting to map events, drawing GeoJSON, and loading the map safely in server-rendered frameworks.

Installing Leaflet and react-leaflet

react-leaflet v5 targets React 19. Install it alongside Leaflet itself and the Leaflet type definitions:

npm install leaflet react-leaflet
npm install -D @types/leaflet

react-leaflet doesn't bundle Leaflet. It renders Leaflet objects for you, so you'll still import from leaflet when you need low-level types or icons.

Your First Map

Every map starts with MapContainer, which creates the Leaflet map instance, and a TileLayer, which loads the map images:

// src/components/map/BasicMap.tsx
import { MapContainer, TileLayer } from "react-leaflet";
import "leaflet/dist/leaflet.css";

export function BasicMap() {
  return (
    <MapContainer
      center={[51.505, -0.09]}
      zoom={13}
      scrollWheelZoom={false}
      style={{ height: 400, width: "100%" }}
    >
      <TileLayer
        attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
        url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
      />
    </MapContainer>
  );
}

Two things trip up almost everyone the first time:

  1. The CSS import is required. Without leaflet/dist/leaflet.css, tiles render as a scrambled grid of images scattered down the page.
  2. The container needs a height. Leaflet fills its container, and an empty div has a height of zero. If your map is invisible, check the height before anything else.

Setting scrollWheelZoom={false} is a usability choice. A map embedded in a long page shouldn't hijack scrolling. Users can still zoom with the buttons or by pinching.

About tile providers

The url template tells Leaflet where to fetch each 256-pixel tile, filling in zoom (z), column (x), and row (y). The public OpenStreetMap tile servers are fine for development and light use, but their usage policy asks heavy users to use a different provider. For production traffic, use a commercial or self-hosted tile service (MapTiler, Stadia Maps, Thunderforest, and others offer free tiers) and keep the attribution they require.

Fixing the Missing Marker Icon

Add a Marker and you'll likely see a broken image. Leaflet's default icon looks up its image URLs relative to the CSS file, which doesn't survive bundling with Vite or webpack. The reliable fix is to import the images yourself and build an icon:

// src/components/map/icons.ts
import L from "leaflet";
import markerIcon from "leaflet/dist/images/marker-icon.png";
import markerIcon2x from "leaflet/dist/images/marker-icon-2x.png";
import markerShadow from "leaflet/dist/images/marker-shadow.png";

export const defaultIcon = L.icon({
  iconUrl: markerIcon,
  iconRetinaUrl: markerIcon2x,
  shadowUrl: markerShadow,
  iconSize: [25, 41],
  iconAnchor: [12, 41],
  popupAnchor: [1, -34],
  shadowSize: [41, 41],
});

// Make it the default for every Marker that doesn't set its own icon
L.Marker.prototype.options.icon = defaultIcon;

Import this file once, near where your map lives, and every marker gets a working icon. The iconAnchor is the pixel in the image that sits on the coordinate. For the default pin that's the bottom center, which is why it's [12, 41] on a 25 by 41 image.

Rendering Markers From Data

Real maps show data. Since markers are components, you render them the same way you render any list:

// src/components/map/StoreMap.tsx
import { MapContainer, TileLayer, Marker, Popup } from "react-leaflet";
import type { LatLngTuple } from "leaflet";
import "leaflet/dist/leaflet.css";
import "./icons";

export type Store = {
  id: string;
  name: string;
  address: string;
  position: LatLngTuple;
};

type StoreMapProps = { stores: Store[] };

export function StoreMap({ stores }: StoreMapProps) {
  return (
    <MapContainer center={[40.7128, -74.006]} zoom={12} style={{ height: 480 }}>
      <TileLayer
        attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
        url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
      />
      {stores.map((store) => (
        <Marker key={store.id} position={store.position}>
          <Popup>
            <strong>{store.name}</strong>
            <br />
            {store.address}
          </Popup>
        </Marker>
      ))}
    </MapContainer>
  );
}

Use a stable ID as the key. If you use the array index and the list is filtered, React will reuse the wrong marker instances and popups can open on the wrong location. The post on why keys matter when rendering lists in React explains the mechanics.

Popup content is regular React, so you can put buttons, images, or links inside. Keep it small, though. A popup is a speech bubble, not a page.

Controlling the Map From Code

Here's the most important thing to understand about react-leaflet: most MapContainer props are only read once, when the map is created. Changing center or zoom in state won't move the map. This is deliberate. The user is moving the map too, and a prop that fights the user would be a bad experience.

To move the map in response to your app's state, you get the Leaflet map instance with the useMap hook and call its methods. useMap only works in components rendered inside MapContainer:

// src/components/map/FlyToSelected.tsx
import { useEffect } from "react";
import { useMap } from "react-leaflet";
import type { LatLngTuple } from "leaflet";

type FlyToSelectedProps = { position: LatLngTuple | null };

export function FlyToSelected({ position }: FlyToSelectedProps) {
  const map = useMap();

  useEffect(() => {
    if (!position) return;
    map.flyTo(position, 15, { duration: 0.8 });
  }, [map, position]);

  return null;
}

Now a sidebar list can drive the map:

import { useState } from "react";
import { MapContainer, TileLayer, Marker, Popup } from "react-leaflet";
import { FlyToSelected } from "./FlyToSelected";
import type { Store } from "./StoreMap";

export function StoreLocator({ stores }: { stores: Store[] }) {
  const [selectedId, setSelectedId] = useState<string | null>(null);
  const selected = stores.find((s) => s.id === selectedId) ?? null;

  return (
    <div className="grid gap-4 md:grid-cols-[280px_1fr]">
      <ul className="space-y-1">
        {stores.map((store) => (
          <li key={store.id}>
            <button
              type="button"
              onClick={() => setSelectedId(store.id)}
              className={store.id === selectedId ? "font-semibold" : ""}
            >
              {store.name}
            </button>
          </li>
        ))}
      </ul>

      <MapContainer center={[40.7128, -74.006]} zoom={12} style={{ height: 480 }}>
        <TileLayer
          attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
          url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
        />
        {stores.map((store) => (
          <Marker key={store.id} position={store.position}>
            <Popup>{store.name}</Popup>
          </Marker>
        ))}
        <FlyToSelected position={selected?.position ?? null} />
      </MapContainer>
    </div>
  );
}

Because selected.position comes from the same array entry each time, the effect only runs when the selection really changes.

Fitting all markers in view

Hardcoding a center is fragile. Usually you want the map to show every marker. Leaflet's fitBounds does that:

import { useEffect } from "react";
import { useMap } from "react-leaflet";
import L, { type LatLngTuple } from "leaflet";

export function FitToMarkers({ positions }: { positions: LatLngTuple[] }) {
  const map = useMap();

  useEffect(() => {
    if (positions.length === 0) return;
    if (positions.length === 1) {
      map.setView(positions[0], 14);
      return;
    }
    map.fitBounds(L.latLngBounds(positions), { padding: [40, 40] });
  }, [map, positions]);

  return null;
}

The single-marker branch matters. Fitting bounds around one point zooms in as far as the map allows, which is rarely what you want. Also make sure positions is a stable array (from state, a query cache, or useMemo), or the effect will re-fit the map on every render and fight the user.

Reacting to Map Events

The other direction is listening to what the user does. useMapEvents attaches Leaflet event handlers and returns the map instance:

// src/components/map/ClickToAdd.tsx
import { useState } from "react";
import { Marker, Popup, useMapEvents } from "react-leaflet";
import type { LatLng } from "leaflet";

export function ClickToAdd() {
  const [points, setPoints] = useState<LatLng[]>([]);

  useMapEvents({
    click(event) {
      setPoints((prev) => [...prev, event.latlng]);
    },
  });

  return (
    <>
      {points.map((point, i) => (
        <Marker key={`${point.lat}-${point.lng}-${i}`} position={point}>
          <Popup>
            {point.lat.toFixed(5)}, {point.lng.toFixed(5)}
          </Popup>
        </Marker>
      ))}
    </>
  );
}

A common real use is loading data for the visible area. Listen for moveend, which fires once after panning or zooming finishes, and read the bounds:

import { useMapEvents } from "react-leaflet";
import type { LatLngBounds } from "leaflet";

type BoundsWatcherProps = { onChange: (bounds: LatLngBounds) => void };

export function BoundsWatcher({ onChange }: BoundsWatcherProps) {
  const map = useMapEvents({
    moveend() {
      onChange(map.getBounds());
    },
  });

  return null;
}

Pass the bounds into your data fetching (for example as part of a TanStack Query key) and you get a "search this area" map. moveend already fires only when movement stops, but if your API is expensive, debounce the callback too.

Draggable Markers

Letting users drag a pin to pick a location is a good alternative to typing an address. react-leaflet passes Leaflet events through the eventHandlers prop:

import { useMemo, useRef, useState } from "react";
import { Marker, Popup } from "react-leaflet";
import type { LatLngTuple, Marker as LeafletMarker } from "leaflet";

export function DraggablePin({ initial }: { initial: LatLngTuple }) {
  const [position, setPosition] = useState<LatLngTuple>(initial);
  const markerRef = useRef<LeafletMarker>(null);

  const eventHandlers = useMemo(
    () => ({
      dragend() {
        const marker = markerRef.current;
        if (!marker) return;
        const { lat, lng } = marker.getLatLng();
        setPosition([lat, lng]);
      },
    }),
    [],
  );

  return (
    <Marker draggable position={position} eventHandlers={eventHandlers} ref={markerRef}>
      <Popup>
        Drop the pin at your entrance. Current: {position[0].toFixed(4)}, {position[1].toFixed(4)}
      </Popup>
    </Marker>
  );
}

The useMemo keeps the handlers object stable. react-leaflet re-binds event handlers when that object changes, so a new object on every render would detach and reattach listeners constantly.

Shapes and GeoJSON

Leaflet has components for circles, polylines, polygons, and rectangles. A delivery radius is a one-liner:

import { Circle } from "react-leaflet";

<Circle center={[40.7128, -74.006]} radius={3000} pathOptions={{ color: "#2563eb", fillOpacity: 0.1 }} />;

For real geographic data like neighborhoods or delivery zones, you'll usually have GeoJSON. The GeoJSON component renders it and lets you style each feature:

import { GeoJSON } from "react-leaflet";
import type { FeatureCollection } from "geojson";
import type { PathOptions } from "leaflet";

type Zone = { name: string; fee: number };

const colorForFee = (fee: number) => (fee === 0 ? "#16a34a" : fee < 5 ? "#ca8a04" : "#dc2626");

export function DeliveryZones({ data }: { data: FeatureCollection }) {
  return (
    <GeoJSON
      data={data}
      style={(feature): PathOptions => {
        const zone = feature?.properties as Zone;
        return { color: colorForFee(zone.fee), weight: 2, fillOpacity: 0.2 };
      }}
      onEachFeature={(feature, layer) => {
        const zone = feature.properties as Zone;
        layer.bindTooltip(`${zone.name}: $${zone.fee} delivery`);
      }}
    />
  );
}

The geojson types come with @types/leaflet as a dependency, so the import works without installing anything else.

One gotcha: the GeoJSON component doesn't redraw when data changes. If you load new data into the same component, give it a key that changes with the data (a version number or dataset ID) so React remounts it.

Using Leaflet With Next.js and Other SSR Frameworks

Leaflet touches window as soon as it's imported, so importing it during server rendering throws window is not defined. The fix is to keep the map in a client-only module and load it without SSR.

In Next.js, put the map in a Client Component and import it with next/dynamic from another Client Component:

"use client";

import dynamic from "next/dynamic";

const StoreMap = dynamic(() => import("./StoreMap").then((m) => m.StoreMap), {
  ssr: false,
  loading: () => <div style={{ height: 480 }} className="animate-pulse bg-slate-100" />,
});

export function StoreMapClient(props: React.ComponentProps<typeof StoreMap>) {
  return <StoreMap {...props} />;
}

The loading placeholder has the same height as the map so the page doesn't shift when it loads. In a plain Vite app you can use React.lazy with Suspense for the same benefit: Leaflet only downloads when the map is actually rendered. The post on code splitting with React.lazy and Suspense covers the pattern.

Performance With Many Markers

Each Leaflet marker is a DOM element. A few hundred are fine. Several thousand will make panning sluggish. Options, from easiest to most involved:

  • Filter by viewport. Only render markers inside the current bounds, using the BoundsWatcher pattern above.
  • Render to canvas. Use CircleMarker instead of Marker and set preferCanvas on MapContainer. Canvas-drawn circles scale to thousands of points.
  • Cluster. The leaflet.markercluster plugin groups nearby markers into numbered clusters that expand as you zoom. Community packages wrap it for react-leaflet.
  • Do the work on the server. For very large datasets, aggregate or tile the data on the backend and send only what's visible.

Common Mistakes With React Leaflet

  • Forgetting the CSS import. Tiles appear scattered or misaligned.
  • No height on the map container. The map renders at zero pixels tall and seems missing.
  • Expecting center and zoom props to update the map. They're initial values. Use useMap and call setView, flyTo, or fitBounds.
  • Calling useMap outside MapContainer. The hook reads context that only exists inside the map, so it throws.
  • Recreating eventHandlers or bounds arrays on every render. Listeners re-bind and effects re-run, fighting the user's panning.
  • Importing Leaflet during SSR. Load the map component client-only.
  • Ignoring tile provider policies. Heavy traffic on the public OSM servers can get you blocked. Use a provider meant for production.

Frequently Asked Questions (FAQ) About Leaflet in React

Yes. Leaflet and react-leaflet are open source. The map images come from a tile provider, and while OpenStreetMap's public tiles are free for light use, production apps should use a provider whose terms fit their traffic. Many offer generous free tiers.

Almost always one of two things: the container has no height, or leaflet/dist/leaflet.css isn't imported. If tiles load but appear in the wrong places, it's the CSS. If nothing shows at all, check the height of the map element in your browser's dev tools.

Leaflet's default icon resolves image paths in a way that breaks under bundlers like Vite and webpack. Import the marker images from leaflet/dist/images, create an icon with L.icon, and set it as the default or pass it to each Marker.

Get the map instance with useMap inside a child of MapContainer and call setView, flyTo, or fitBounds in an effect. Changing the center prop does nothing after the first render.

Leaflet uses raster tiles and DOM markers, which makes it small, simple, and easy to style with CSS. Mapbox GL and MapLibre GL render vector tiles with WebGL, which gives smoother zooming, rotation, 3D, and better performance with very large datasets. For most store locators and listing maps, Leaflet is enough.

Yes. Call map.locate() from a component that uses useMap, and listen for the locationfound event with useMapEvents. The browser asks for permission first, and it only works on secure origins such as HTTPS or localhost.

Conclusion

Leaflet and react-leaflet give you interactive maps without API keys or billing setup. Import the CSS, give the container a height, fix the default marker icon once, and render markers from your data like any other list. When the map needs to respond to your app, reach for useMap. When your app needs to respond to the map, use useMapEvents or eventHandlers.

A good next step is building a full "search this area" store locator: watch moveend, pass the bounds into a query, and render the results both as markers and as a list beside the map. Load the whole thing lazily so users who never scroll to the map never download it.

Tags :
Share :

Related Posts

A Practical Guide to useEffect and Its Dependency Array

A Practical Guide to useEffect and Its Dependency Array

useEffect is the hook people get wrong most often, and the dependency array is usually where it goes wrong. Leave a value out and your effect works

Continue Reading
Accessibility Best Practices for React Developers

Accessibility Best Practices for React Developers

React makes it easy to build interfaces out of anything. A div with an onClick looks and behaves like a button for a mouse user, so it ships. The

Continue Reading
Animations in React with Motion (Framer Motion)

Animations in React with Motion (Framer Motion)

CSS transitions get you far, until you need to animate something leaving the page. React removes the element from the DOM immediately, so there's not

Continue Reading