Skip to main content
Technology & EngineeringMaps Geolocation Services262 lines

Leaflet

"Leaflet: open-source maps, markers, popups, layers, GeoJSON, plugins, React (react-leaflet), tile providers"

Quick Summary16 lines
Leaflet is a lightweight, open-source mapping library that prioritizes simplicity, performance, and extensibility. At ~42KB gzipped, it delivers a complete mapping experience without the weight of commercial SDKs. Leaflet renders raster tiles by default and does not require WebGL, making it compatible with virtually every browser and device. Its plugin ecosystem fills gaps the core intentionally leaves out -- clustering, heatmaps, routing, drawing -- keeping the base library focused. In React, use `react-leaflet` v4+ which provides a declarative component API while preserving access to the underlying Leaflet instances through refs and the `useMap` hook. Leaflet is tile-provider-agnostic: you can use OpenStreetMap, Mapbox, Stadia Maps, Thunderforest, or your own tile server.

## Key Points

- **Fix marker icons early**: Bundlers (Webpack, Vite) break Leaflet's default icon paths. Apply the `mergeOptions` fix at app initialization, not inside components.
- **Use `whenReady` or `useMap` for post-init logic**: Never access the map instance before it finishes initializing. The `useMap` hook guarantees the instance is ready.
- **Key GeoJSON components for re-rendering**: `react-leaflet`'s `<GeoJSON>` does not update when data changes. Pass a unique `key` prop to force remount when the data object changes.
- **Respect tile provider terms**: OpenStreetMap's tile servers have a usage policy. For production traffic, use a commercial provider or host your own tile server.
- **Debounce viewport-dependent data fetches**: Listen to `moveend` (not `move`) and debounce to avoid overwhelming your API on every pan frame.
- **Set `maxBounds` for constrained maps**: If your app only covers a specific region, set `maxBounds` on the `MapContainer` to prevent users from scrolling to irrelevant areas.
- **Including Leaflet CSS only in the component file**: The CSS must be loaded globally or at the layout level. Missing CSS causes tiles to render in a broken grid pattern.
- **Creating L.map() manually in React**: Never call `L.map()` yourself alongside `react-leaflet`. Use `<MapContainer>` exclusively -- it owns the Leaflet instance lifecycle.
- **Ignoring the `key` prop on GeoJSON**: Without a fresh key, the GeoJSON layer keeps stale data. This is the most common bug in react-leaflet apps.
- **Loading heavy plugins unconditionally**: Plugins like `leaflet-draw` and `leaflet.heat` add significant bundle weight. Dynamic-import them only on pages that use drawing or heatmap features.
skilldb get maps-geolocation-services-skills/LeafletFull skill: 262 lines
Paste into your CLAUDE.md or agent config

Leaflet

Core Philosophy

Leaflet is a lightweight, open-source mapping library that prioritizes simplicity, performance, and extensibility. At ~42KB gzipped, it delivers a complete mapping experience without the weight of commercial SDKs. Leaflet renders raster tiles by default and does not require WebGL, making it compatible with virtually every browser and device. Its plugin ecosystem fills gaps the core intentionally leaves out -- clustering, heatmaps, routing, drawing -- keeping the base library focused. In React, use react-leaflet v4+ which provides a declarative component API while preserving access to the underlying Leaflet instances through refs and the useMap hook. Leaflet is tile-provider-agnostic: you can use OpenStreetMap, Mapbox, Stadia Maps, Thunderforest, or your own tile server.

Setup

// npm install leaflet react-leaflet @types/leaflet
import { MapContainer, TileLayer, Marker, Popup, useMap } from "react-leaflet";
import L from "leaflet";
import "leaflet/dist/leaflet.css";

// Fix default marker icon paths (common issue with bundlers)
import markerIcon2x from "leaflet/dist/images/marker-icon-2x.png";
import markerIcon from "leaflet/dist/images/marker-icon.png";
import markerShadow from "leaflet/dist/images/marker-shadow.png";

L.Icon.Default.mergeOptions({
  iconRetinaUrl: markerIcon2x,
  iconUrl: markerIcon,
  shadowUrl: markerShadow,
});

Basic map component:

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

Key Techniques

Custom Markers with Icons

const coffeeIcon = L.icon({
  iconUrl: "/icons/coffee.png",
  iconSize: [32, 32],
  iconAnchor: [16, 32],
  popupAnchor: [0, -32],
});

function CoffeeShopMarkers({ shops }: { shops: Array<{ id: string; name: string; lat: number; lng: number }> }) {
  return (
    <>
      {shops.map((shop) => (
        <Marker key={shop.id} position={[shop.lat, shop.lng]} icon={coffeeIcon}>
          <Popup>
            <strong>{shop.name}</strong>
          </Popup>
        </Marker>
      ))}
    </>
  );
}

GeoJSON Layer with Interactive Styling

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

function ChoroplethLayer({ data }: { data: GeoJSON.FeatureCollection }) {
  function getColor(value: number): string {
    if (value > 1000) return "#800026";
    if (value > 500) return "#BD0026";
    if (value > 200) return "#E31A1C";
    if (value > 100) return "#FC4E2A";
    return "#FED976";
  }

  function style(feature: Feature | undefined) {
    const value = feature?.properties?.density ?? 0;
    return {
      fillColor: getColor(value),
      weight: 2,
      opacity: 1,
      color: "white",
      dashArray: "3",
      fillOpacity: 0.7,
    };
  }

  function onEachFeature(feature: Feature, layer: L.Layer) {
    layer.on({
      mouseover: (e: L.LeafletMouseEvent) => {
        const target = e.target as L.Path;
        target.setStyle({ weight: 5, color: "#666", dashArray: "", fillOpacity: 0.9 });
        target.bringToFront();
      },
      mouseout: (e: L.LeafletMouseEvent) => {
        const target = e.target as L.Path;
        target.setStyle(style(feature));
      },
      click: () => {
        alert(`${feature.properties?.name}: ${feature.properties?.density}`);
      },
    });
  }

  return <GeoJSON data={data} style={style} onEachFeature={onEachFeature} />;
}

Map Controls and Programmatic Navigation

import { useMap } from "react-leaflet";

function FlyToLocation({ position }: { position: [number, number] }) {
  const map = useMap();

  useEffect(() => {
    map.flyTo(position, 14, { duration: 1.5 });
  }, [map, position]);

  return null;
}

function FitBoundsToMarkers({ positions }: { positions: [number, number][] }) {
  const map = useMap();

  useEffect(() => {
    if (positions.length === 0) return;
    const bounds = L.latLngBounds(positions);
    map.fitBounds(bounds, { padding: [50, 50] });
  }, [map, positions]);

  return null;
}

Marker Clustering

// npm install react-leaflet-cluster
import MarkerClusterGroup from "react-leaflet-cluster";

function ClusteredMap({ points }: { points: Array<{ lat: number; lng: number; label: string }> }) {
  return (
    <MapContainer center={[51.505, -0.09]} zoom={10} style={{ height: "100vh", width: "100%" }}>
      <TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
      <MarkerClusterGroup
        chunkedLoading
        maxClusterRadius={60}
        spiderfyOnMaxZoom
        showCoverageOnHover={false}
      >
        {points.map((point, i) => (
          <Marker key={i} position={[point.lat, point.lng]}>
            <Popup>{point.label}</Popup>
          </Marker>
        ))}
      </MarkerClusterGroup>
    </MapContainer>
  );
}

Layer Control for Multiple Tile Providers

import { LayersControl, TileLayer } from "react-leaflet";

function MultiLayerMap() {
  return (
    <MapContainer center={[51.505, -0.09]} zoom={13} style={{ height: "100vh", width: "100%" }}>
      <LayersControl position="topright">
        <LayersControl.BaseLayer checked name="OpenStreetMap">
          <TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
        </LayersControl.BaseLayer>
        <LayersControl.BaseLayer name="Satellite">
          <TileLayer
            url="https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"
            attribution="Tiles &copy; Esri"
          />
        </LayersControl.BaseLayer>
        <LayersControl.BaseLayer name="Topo">
          <TileLayer
            url="https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png"
            attribution="&copy; OpenTopoMap"
          />
        </LayersControl.BaseLayer>
      </LayersControl>
    </MapContainer>
  );
}

Drawing Shapes with Leaflet.draw

// npm install leaflet-draw @types/leaflet-draw
import "leaflet-draw/dist/leaflet.draw.css";
import { FeatureGroup } from "react-leaflet";
import { EditControl } from "react-leaflet-draw";

function DrawableMap({ onShapeCreated }: { onShapeCreated: (geoJson: GeoJSON.Feature) => void }) {
  const handleCreated = (e: L.DrawEvents.Created) => {
    const layer = e.layer as L.Polygon | L.Circle | L.Rectangle;
    const geoJson = (layer as L.Polygon).toGeoJSON();
    onShapeCreated(geoJson);
  };

  return (
    <FeatureGroup>
      <EditControl
        position="topright"
        onCreated={handleCreated}
        draw={{
          rectangle: true,
          polygon: true,
          circle: false, // circle not natively GeoJSON-compatible
          polyline: false,
          marker: false,
          circlemarker: false,
        }}
      />
    </FeatureGroup>
  );
}

Best Practices

  • Fix marker icons early: Bundlers (Webpack, Vite) break Leaflet's default icon paths. Apply the mergeOptions fix at app initialization, not inside components.
  • Use whenReady or useMap for post-init logic: Never access the map instance before it finishes initializing. The useMap hook guarantees the instance is ready.
  • Key GeoJSON components for re-rendering: react-leaflet's <GeoJSON> does not update when data changes. Pass a unique key prop to force remount when the data object changes.
  • Respect tile provider terms: OpenStreetMap's tile servers have a usage policy. For production traffic, use a commercial provider or host your own tile server.
  • Debounce viewport-dependent data fetches: Listen to moveend (not move) and debounce to avoid overwhelming your API on every pan frame.
  • Set maxBounds for constrained maps: If your app only covers a specific region, set maxBounds on the MapContainer to prevent users from scrolling to irrelevant areas.

Anti-Patterns

  • Including Leaflet CSS only in the component file: The CSS must be loaded globally or at the layout level. Missing CSS causes tiles to render in a broken grid pattern.
  • Creating L.map() manually in React: Never call L.map() yourself alongside react-leaflet. Use <MapContainer> exclusively -- it owns the Leaflet instance lifecycle.
  • Adding thousands of individual markers without clustering: Leaflet's DOM-based rendering slows dramatically past ~1,000 markers. Always use MarkerClusterGroup or switch to Canvas/SVG renderers for large datasets.
  • Using setView inside render without guards: Calling map.setView() on every render creates an infinite loop if the view change triggers a re-render. Gate navigation calls behind a change check.
  • Ignoring the key prop on GeoJSON: Without a fresh key, the GeoJSON layer keeps stale data. This is the most common bug in react-leaflet apps.
  • Loading heavy plugins unconditionally: Plugins like leaflet-draw and leaflet.heat add significant bundle weight. Dynamic-import them only on pages that use drawing or heatmap features.

Install this skill directly: skilldb add maps-geolocation-services-skills

Get CLI access →