Interactive maps improve user experience for location-based apps. Here is how to add them to your Next.js app.
Option 1: Mapbox GL JS
pnpm add mapbox-gl @types/mapbox-gl
// components/MapboxMap.tsx
"use client";
import mapboxgl from "mapbox-gl";
import "mapbox-gl/dist/mapbox-gl.css";
import { useEffect, useRef, useState } from "react";
interface MapMarker {
lng: number;
lat: number;
label?: string;
color?: string;
}
interface MapboxMapProps {
center?: [number, number];
zoom?: number;
markers?: MapMarker[];
onMarkerClick?: (marker: MapMarker) => void;
className?: string;
}
export function MapboxMap({
center = [-73.9857, 40.7484],
zoom = 12,
markers = [],
onMarkerClick,
className = "w-full h-96 rounded-lg",
}: MapboxMapProps) {
const containerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<mapboxgl.Map | null>(null);
const markersRef = useRef<mapboxgl.Marker[]>([]);
useEffect(() => {
if (!containerRef.current) return;
mapboxgl.accessToken = process.env.NEXT_PUBLIC_MAPBOX_TOKEN!;
const map = new mapboxgl.Map({
container: containerRef.current,
style: "mapbox://styles/mapbox/streets-v12",
center,
zoom,
});
map.addControl(new mapboxgl.NavigationControl(), "top-right");
map.addControl(
new mapboxgl.GeolocateControl({
positionOptions: { enableHighAccuracy: true },
trackUserLocation: true,
showUserHeading: true,
}),
"top-right"
);
mapRef.current = map;
return () => {
markersRef.current.forEach((m) => m.remove());
map.remove();
};
}, []);
// Update markers whenever they change
useEffect(() => {
const map = mapRef.current;
if (!map) return;
markersRef.current.forEach((m) => m.remove());
markersRef.current = [];
markers.forEach((m) => {
const popup = m.label
? new mapboxgl.Popup({ offset: 25 }).setHTML(
`<strong>${m.label}</strong>`
)
: undefined;
const marker = new mapboxgl.Marker({ color: m.color ?? "#3b82f6" })
.setLngLat([m.lng, m.lat])
.setPopup(popup)
.addTo(map);
if (onMarkerClick) {
marker.getElement().addEventListener("click", () => onMarkerClick(m));
}
markersRef.current.push(marker);
});
if (markers.length > 1) {
const bounds = new mapboxgl.LngLatBounds();
markers.forEach((m) => bounds.extend([m.lng, m.lat]));
map.fitBounds(bounds, { padding: 60 });
}
}, [markers, onMarkerClick]);
return <div ref={containerRef} className={className} />;
}
Option 2: Leaflet (Open Source)
pnpm add leaflet react-leaflet @types/leaflet
// components/LeafletMap.tsx
"use client";
import dynamic from "next/dynamic";
const MapInner = dynamic(() => import("./LeafletMapInner"), { ssr: false });
export function LeafletMap(props: React.ComponentProps<typeof MapInner>) {
return <MapInner {...props} />;
}
// components/LeafletMapInner.tsx
"use client";
import "leaflet/dist/leaflet.css";
import L from "leaflet";
import { MapContainer, TileLayer, Marker, Popup, useMap } from "react-leaflet";
import { useEffect } from "react";
// Fix default icon paths
delete (L.Icon.Default.prototype as any)._getIconUrl;
L.Icon.Default.mergeOptions({
iconRetinaUrl: "/images/marker-icon-2x.png",
iconUrl: "/images/marker-icon.png",
shadowUrl: "/images/marker-shadow.png",
});
interface Location {
lat: number;
lng: number;
name: string;
description?: string;
}
interface LeafletMapProps {
locations: Location[];
center?: [number, number];
zoom?: number;
}
function FitBounds({ locations }: { locations: Location[] }) {
const map = useMap();
useEffect(() => {
if (locations.length > 1) {
const bounds = L.latLngBounds(locations.map((l) => [l.lat, l.lng]));
map.fitBounds(bounds, { padding: [40, 40] });
}
}, [locations, map]);
return null;
}
export default function LeafletMapInner({
locations,
center = [40.7484, -73.9857],
zoom = 12,
}: LeafletMapProps) {
return (
<MapContainer
center={center}
zoom={zoom}
className="w-full h-96 rounded-lg z-0"
>
<TileLayer
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
{locations.map((location, i) => (
<Marker key={i} position={[location.lat, location.lng]}>
<Popup>
<strong>{location.name}</strong>
{location.description && <p>{location.description}</p>}
</Popup>
</Marker>
))}
<FitBounds locations={locations} />
</MapContainer>
);
}
Geolocation Hook
// hooks/useGeolocation.ts
"use client";
import { useCallback, useState } from "react";
interface GeolocationState {
latitude: number | null;
longitude: number | null;
accuracy: number | null;
loading: boolean;
error: string | null;
}
export function useGeolocation() {
const [state, setState] = useState<GeolocationState>({
latitude: null,
longitude: null,
accuracy: null,
loading: false,
error: null,
});
const requestLocation = useCallback(() => {
if (!navigator.geolocation) {
setState((s) => ({ ...s, error: "Geolocation not supported" }));
return;
}
setState((s) => ({ ...s, loading: true, error: null }));
navigator.geolocation.getCurrentPosition(
(position) => {
setState({
latitude: position.coords.latitude,
longitude: position.coords.longitude,
accuracy: position.coords.accuracy,
loading: false,
error: null,
});
},
(err) => {
setState((s) => ({ ...s, loading: false, error: err.message }));
},
{ enableHighAccuracy: true, timeout: 10000 }
);
}, []);
return { ...state, requestLocation };
}
Geocoding API Route
// app/api/geocode/route.ts
import { NextRequest, NextResponse } from "next/server";
export async function GET(request: NextRequest) {
const query = request.nextUrl.searchParams.get("q");
if (!query) {
return NextResponse.json({ error: "Query required" }, { status: 400 });
}
const token = process.env.MAPBOX_TOKEN;
const encoded = encodeURIComponent(query);
const url = `https://api.mapbox.com/geocoding/v5/mapbox.places/${encoded}.json?access_token=${token}&limit=5`;
const res = await fetch(url);
const data = await res.json();
const results = data.features.map(
(f: { place_name: string; center: [number, number] }) => ({
name: f.place_name,
lng: f.center[0],
lat: f.center[1],
})
);
return NextResponse.json(results);
}
Need a Location-Based App?
We build mapping features, store locators, and geolocation-driven experiences. Contact us to scope your project.