Skip to main content
Back to Blog
Tutorials
3 min read
December 18, 2024

How to Add Maps and Geolocation to a Next.js App

Integrate interactive maps and geolocation into your Next.js app using Mapbox GL JS and Leaflet with custom markers, geocoding, and route display.

Ryel Banfield

Founder & Lead Developer

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='&copy; <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.

mapsgeolocationMapboxLeafletNext.jstutorial

Ready to Start Your Project?

RCB Software builds world-class websites and applications for businesses worldwide.

Get in Touch

Related Articles