React Maps: Mapbox and Leaflet Interactive Maps (2026)

Interactive maps are a common requirement for location-aware React apps. This guide covers two main options: react-map-gl (Mapbox GL JS wrapper — best for data-heavy, styled maps) and react-leaflet (OpenStreetMap-based, free, no API key). Topics include custom markers, popups, GeoJSON layers, marker clustering, geocoding search and dark map styles.

Mapbox with react-map-gl

npm install react-map-gl mapbox-gl

// Mapbox requires a free API token from mapbox.com
// Add to .env: VITE_MAPBOX_TOKEN=pk.eyJ1Ijoie...

import Map, { NavigationControl, GeolocateControl } from 'react-map-gl'
import 'mapbox-gl/dist/mapbox-gl.css'

function MapboxMap() {
  const [viewState, setViewState] = useState({
    longitude: -122.4194,
    latitude: 37.7749,
    zoom: 12,
  })

  return (
    <Map
      {...viewState}
      onMove={e => setViewState(e.viewState)}
      style={{ width: '100%', height: 500 }}
      mapStyle="mapbox://styles/mapbox/dark-v11"   // Dark theme
      mapboxAccessToken={import.meta.env.VITE_MAPBOX_TOKEN}
    >
      <NavigationControl position="top-right" />
      <GeolocateControl
        position="top-right"
        trackUserLocation
        showUserHeading
      />
    </Map>
  )
}

// Other built-in map styles:
// mapbox://styles/mapbox/streets-v12
// mapbox://styles/mapbox/satellite-streets-v12
// mapbox://styles/mapbox/outdoors-v12
// mapbox://styles/mapbox/light-v11

Custom Markers and Popups

import Map, { Marker, Popup } from 'react-map-gl'

interface Location {
  id: string
  name: string
  longitude: number
  latitude: number
  description: string
}

function LocationMap({ locations }: { locations: Location[] }) {
  const [selected, setSelected] = useState<Location | null>(null)

  return (
    <Map
      initialViewState={{ longitude: -98, latitude: 38.5, zoom: 4 }}
      style={{ width: '100%', height: 500 }}
      mapStyle="mapbox://styles/mapbox/dark-v11"
      mapboxAccessToken={import.meta.env.VITE_MAPBOX_TOKEN}
    >
      {locations.map(loc => (
        <Marker
          key={loc.id}
          longitude={loc.longitude}
          latitude={loc.latitude}
          anchor="bottom"
          onClick={e => {
            e.originalEvent.stopPropagation()
            setSelected(loc)
          }}
        >
          {/* Custom marker — any React element */}
          <div style={{
            width: 32, height: 32, borderRadius: '50%',
            background: 'linear-gradient(135deg,#6366f1,#22d3ee)',
            border: '2px solid #fff',
            cursor: 'pointer',
            display: 'flex', alignItems: 'center', justifyContent: 'center',
          }}>
            📍
          </div>
        </Marker>
      ))}

      {selected && (
        <Popup
          longitude={selected.longitude}
          latitude={selected.latitude}
          anchor="top"
          onClose={() => setSelected(null)}
          closeOnClick={false}
          style={{ zIndex: 10 }}
        >
          <div style={{ padding: 8, minWidth: 160 }}>
            <h4 style={{ margin: '0 0 4px', fontSize: 14 }}>{selected.name}</h4>
            <p style={{ margin: 0, fontSize: 12, color: '#64748b' }}>{selected.description}</p>
          </div>
        </Popup>
      )}
    </Map>
  )
}

GeoJSON Layers

import Map, { Source, Layer } from 'react-map-gl'
import type { LayerProps } from 'react-map-gl'

const clusterLayer: LayerProps = {
  id: 'clusters',
  type: 'circle',
  source: 'earthquakes',
  filter: ['has', 'point_count'],
  paint: {
    'circle-color': ['step', ['get', 'point_count'], '#6366f1', 100, '#22d3ee', 750, '#f59e0b'],
    'circle-radius': ['step', ['get', 'point_count'], 20, 100, 30, 750, 40],
  },
}

const unclusteredPointLayer: LayerProps = {
  id: 'unclustered-point',
  type: 'circle',
  source: 'earthquakes',
  filter: ['!', ['has', 'point_count']],
  paint: {
    'circle-color': '#6366f1',
    'circle-radius': 8,
    'circle-stroke-width': 2,
    'circle-stroke-color': '#fff',
  },
}

function EarthquakeMap() {
  return (
    <Map
      initialViewState={{ longitude: -103.59, latitude: 40.67, zoom: 3 }}
      style={{ width: '100%', height: 500 }}
      mapStyle="mapbox://styles/mapbox/dark-v11"
      mapboxAccessToken={import.meta.env.VITE_MAPBOX_TOKEN}
    >
      <Source
        id="earthquakes"
        type="geojson"
        data="https://docs.mapbox.com/mapbox-gl-js/assets/earthquakes.geojson"
        cluster={true}
        clusterMaxZoom={14}
        clusterRadius={50}
      >
        <Layer {...clusterLayer} />
        <Layer {...unclusteredPointLayer} />
      </Source>
    </Map>
  )
}

Leaflet with react-leaflet

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

// Leaflet CSS — required
import 'leaflet/dist/leaflet.css'
// Fix default icon path (Webpack/Vite asset handling breaks Leaflet icons)
import L from 'leaflet'
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'
delete (L.Icon.Default.prototype as any)._getIconUrl
L.Icon.Default.mergeOptions({ iconRetinaUrl: markerIcon2x, iconUrl: markerIcon, shadowUrl: markerShadow })

import { MapContainer, TileLayer, Marker, Popup, useMap } from 'react-leaflet'

function LeafletMap({ locations }: { locations: Location[] }) {
  return (
    <MapContainer
      center={[37.7749, -122.4194]}
      zoom={12}
      style={{ height: 500, width: '100%' }}
    >
      {/* OpenStreetMap tiles — free, no API key */}
      <TileLayer
        url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
        attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
      />
      {/* Dark tiles from CartoDB */}
      {/* url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png" */}

      {locations.map(loc => (
        <Marker key={loc.id} position={[loc.latitude, loc.longitude]}>
          <Popup>
            <strong>{loc.name}</strong><br />{loc.description}
          </Popup>
        </Marker>
      ))}
    </MapContainer>
  )
}

// Fly to a location programmatically
function FlyToButton({ coords }: { coords: [number, number] }) {
  const map = useMap()   // Must be used inside MapContainer
  return (
    <button onClick={() => map.flyTo(coords, 14, { duration: 1.5 })}>
      Go to location
    </button>
  )
}

Marker Clustering

// react-leaflet with Leaflet.markercluster
npm install react-leaflet-cluster

import MarkerClusterGroup from 'react-leaflet-cluster'

<MapContainer center={[51.505, -0.09]} zoom={3} style={{ height: 500 }}>
  <TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
  <MarkerClusterGroup chunkedLoading>
    {locations.map(loc => (
      <Marker key={loc.id} position={[loc.lat, loc.lng]}>
        <Popup>{loc.name}</Popup>
      </Marker>
    ))}
  </MarkerClusterGroup>
</MapContainer>

Geocoding Search

// Address → coordinates using Mapbox Geocoding API
async function geocode(query: string) {
  const url = `https://api.mapbox.com/geocoding/v5/mapbox.places/${encodeURIComponent(query)}.json`
    + `?access_token=${import.meta.env.VITE_MAPBOX_TOKEN}&limit=5`
  const res = await fetch(url)
  const data = await res.json()
  return data.features.map((f: any) => ({
    id: f.id,
    place_name: f.place_name,
    longitude: f.center[0],
    latitude: f.center[1],
  }))
}

function GeocoderSearch({ onSelect }: { onSelect: (lng: number, lat: number) => void }) {
  const [query, setQuery] = useState('')
  const [results, setResults] = useState<any[]>([])

  const search = useDebouncedCallback(async (q: string) => {
    if (q.length < 3) return setResults([])
    setResults(await geocode(q))
  }, 300)

  return (
    <div style={{ position: 'relative' }}>
      <input
        value={query}
        onChange={e => { setQuery(e.target.value); search(e.target.value) }}
        placeholder="Search address..."
      />
      {results.length > 0 && (
        <ul style={{ position: 'absolute', top: '100%', background: '#0d1424', listStyle: 'none', padding: 0, margin: 0, border: '1px solid rgba(99,102,241,.3)', borderRadius: 8, zIndex: 100 }}>
          {results.map(r => (
            <li key={r.id} style={{ padding: '8px 12px', cursor: 'pointer', color: '#e2e8f0' }}
              onClick={() => { setQuery(r.place_name); setResults([]); onSelect(r.longitude, r.latitude) }}>
              {r.place_name}
            </li>
          ))}
        </ul>
      )}
    </div>
  )
}

Mapbox vs Leaflet

FeatureMapbox (react-map-gl)Leaflet (react-leaflet)
CostFree tier, then pay-per-loadFree (OpenStreetMap)
PerformanceWebGL — handles 1M+ pointsSVG/Canvas — good to ~10k markers
Custom stylesMapbox Studio (full control)Tile URL swap only
3D terrainYes (terrain + sky layers)No
Bundle size~250 KB (mapbox-gl)~42 KB
Best forData-rich, styled appsSimple maps, no API key