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.
Table of Contents
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='© <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
| Feature | Mapbox (react-map-gl) | Leaflet (react-leaflet) |
|---|---|---|
| Cost | Free tier, then pay-per-load | Free (OpenStreetMap) |
| Performance | WebGL — handles 1M+ points | SVG/Canvas — good to ~10k markers |
| Custom styles | Mapbox Studio (full control) | Tile URL swap only |
| 3D terrain | Yes (terrain + sky layers) | No |
| Bundle size | ~250 KB (mapbox-gl) | ~42 KB |
| Best for | Data-rich, styled apps | Simple maps, no API key |