Build Modern Maps in Next.js with Mapbox and shadcn/ui

Build Modern Maps in Next.js with Mapbox and shadcn/ui


If you’ve ever worked with Google Maps, you know it’s powerful — but it can get expensive quickly, especially as your app scales. That’s why I turned to Mapbox, a robust, cost-effective alternative with stunning visuals, rich features, and great developer tools.

In this post, I’ll walk you through how I built a modern mapping application using Mapbox GL JS, the latest Next.js, shadcn/ui components, and Tailwind CSS. This stack allows you to create a sleek, responsive, and customizable mapping experience with features like:

  • 📍 Dynamic markers
  • 💬 Custom popups
  • 🌗 Dark/light theme support
  • ⚙️ Shared map state with React context
  • 🧩 Reusable, clean component design



Prerequisites

Before we begin, make sure you have:

  1. A Mapbox account and API key
  2. Node.js and npm/yarn installed
  3. Basic knowledge of React, Next.js, and Tailwind CSS



Project Setup

Let’s start by setting up a new Next.js project with Tailwind CSS and shadcn/ui.

npx create-next-app@latest mapbox-nextjs
cd mapbox-nextjs
Enter fullscreen mode

Exit fullscreen mode

When prompted, select:

  • TypeScript: Yes
  • ESLint: Yes
  • Tailwind CSS: Yes
  • App Router: Yes
  • Import aliases: Yes (default: @/*)

Next, let’s install shadcn/ui and the required dependencies:

npx shadcn@latest init
Enter fullscreen mode

Exit fullscreen mode

Now, let’s install Mapbox GL JS:

npm install mapbox-gl
Enter fullscreen mode

Exit fullscreen mode



Setting Up Environment Variables

Create a .env.local file in your project root and add your Mapbox token:

NEXT_PUBLIC_MAPBOX_TOKEN=your_mapbox_token_here
NEXT_PUBLIC_MAPBOX_SESSION_TOKEN=your_session_token_here 
Enter fullscreen mode

Exit fullscreen mode



Project Structure (Simplified)

src/
├── app/
│   ├── layout.tsx
│   └── page.tsx                 ← Map rendering entry point
├── components/
│   ├── location-marker.tsx      ← Marker component
│   ├── location-popup.tsx       ← Popup component
│   ├── map/                     ← Core map UI features
│   │   ├── map-marker.tsx       ← Resusable markers
│   │   ├── map-popup.tsx        ← Resusable popup logic
│   │   ├── map-controls.tsx     ← Zoom/rotation controls
│   │   ├── map-styles.tsx       ← Dark/light mode styles
│   │   └── map-search.tsx       ← Autocomplete & geocoding
│   └── ui/                      ← shadcn/ui components
├── context/
│   └── map-context.ts           ← Shared map state
├── lib/
│   └── mapbox/
│       ├── provider.tsx         ← Map lifecycle & theme-aware setup
│       └── utils.tsx            ← Utilities: center calc, types, icons, etc.
Enter fullscreen mode

Exit fullscreen mode



Creating the Map Context

First, let’s create a context to manage our Mapbox instance. This will allow us to access the map from any component in our application.

// map-context.ts
import { createContext, useContext } from "react";

interface MapContextType {
  map: mapboxgl.Map;
}

export const MapContext = createContext(null);

export function useMap() {
  const context = useContext(MapContext);
  if (!context) {
    throw new Error("useMap must be used within a MapProvider");
  }
  return context;
}
Enter fullscreen mode

Exit fullscreen mode



Building the Map Provider

Next, let’s create a Map Provider component that initializes the Mapbox map and provides it to our application through the context.

// lib/mapbox/provider.tsx

"use client";

import React, { useEffect, useRef, useState } from "react";
import mapboxgl from "mapbox-gl";
import "mapbox-gl/dist/mapbox-gl.css";

import { MapContext } from "@/context/map-context";

mapboxgl.accessToken = process.env.NEXT_PUBLIC_MAPBOX_TOKEN!;

type MapComponentProps = {
  mapContainerRef: React.RefObject;
  initialViewState: {
    longitude: number;
    latitude: number;
    zoom: number;
  };
  children?: React.ReactNode;
};

export default function MapProvider({
  mapContainerRef,
  initialViewState,
  children,
}: MapComponentProps) {
  const map = useRef(null);
  const [loaded, setLoaded] = useState(false);

  useEffect(() => {
    if (!mapContainerRef.current || map.current) return;

    map.current = new mapboxgl.Map({
      container: mapContainerRef.current,
      style: "mapbox://styles/mapbox/standard",
      center: [initialViewState.longitude, initialViewState.latitude],
      zoom: initialViewState.zoom,
      attributionControl: false,
      logoPosition: "bottom-right",
    });

    map.current.on("load", () => {
      setLoaded(true);
    });

    return () => {
      if (map.current) {
        map.current.remove();
        map.current = null;
      }
    };
  }, [initialViewState, mapContainerRef]);

  return (
    
{children} {!loaded && ( )}
); }
Enter fullscreen mode

Exit fullscreen mode



Creating Map Components

Now, let’s build the core map components that will enhance our map’s functionality.



1. Custom Marker Component

// components/map/map-marker.tsx

"use client";

import mapboxgl, { MarkerOptions } from "mapbox-gl";
import React, { useEffect, useRef } from "react";

import { useMap } from "@/context/map-context";
import { LocationFeature } from "@/lib/mapbox/utils";

type Props = {
  longitude: number;
  latitude: number;
  data: any;
  onHover?: ({
    isHovered,
    position,
    marker,
    data,
  }: {
    isHovered: boolean;
    position: { longitude: number; latitude: number };
    marker: mapboxgl.Marker;
    data: LocationFeature;
  }) => void;
  onClick?: ({
    position,
    marker,
    data,
  }: {
    position: { longitude: number; latitude: number };
    marker: mapboxgl.Marker;
    data: LocationFeature;
  }) => void;
  children?: React.ReactNode;
} & MarkerOptions;

export default function Marker({
  children,
  latitude,
  longitude,
  data,
  onHover,
  onClick,
  ...props
}: Props) {
  const { map } = useMap();
  const markerRef = useRef(null);
  let marker: mapboxgl.Marker | null = null;

  const handleHover = (isHovered: boolean) => {
    if (onHover && marker) {
      onHover({
        isHovered,
        position: { longitude, latitude },
        marker,
        data,
      });
    }
  };

  const handleClick = () => {
    if (onClick && marker) {
      onClick({
        position: { longitude, latitude },
        marker,
        data,
      });
    }
  };

  useEffect(() => {
    const markerEl = markerRef.current;
    if (!map || !markerEl) return;

    const handleMouseEnter = () => handleHover(true);
    const handleMouseLeave = () => handleHover(false);

    // Add event listeners
    markerEl.addEventListener("mouseenter", handleMouseEnter);
    markerEl.addEventListener("mouseleave", handleMouseLeave);
    markerEl.addEventListener("click", handleClick);

    // Marker options
    const options = {
      element: markerEl,
      ...props,
    };

    marker = new mapboxgl.Marker(options)
      .setLngLat([longitude, latitude])
      .addTo(map);

    return () => {
      // Cleanup on unmount
      if (marker) marker.remove();
      if (markerEl) {
        markerEl.removeEventListener("mouseenter", handleMouseEnter);
        markerEl.removeEventListener("mouseleave", handleMouseLeave);
        markerEl.removeEventListener("click", handleClick);
      }
    };
  }, [map, longitude, latitude, props]);

  return (
    
  );
}
Enter fullscreen mode

Exit fullscreen mode



2. Custom Popup Component

// components/map/map-popup.tsx

"use client";

import { useMap } from "@/context/map-context";
import mapboxgl from "mapbox-gl";
import { useCallback, useEffect, useMemo } from "react";
import { createPortal } from "react-dom";

type PopupProps = {
  children: React.ReactNode;
  latitude?: number;
  longitude?: number;
  onClose?: () => void;
  marker?: mapboxgl.Marker;
} & mapboxgl.PopupOptions;

export default function Popup({
  latitude,
  longitude,
  children,
  marker,
  onClose,
  className,
  ...props
}: PopupProps) {
  const { map } = useMap();

  const container = useMemo(() => {
    return document.createElement("div");
  }, []);

  const handleClose = useCallback(() => {
    onClose?.();
  }, [onClose]);

  useEffect(() => {
    if (!map) return;

    const popupOptions: mapboxgl.PopupOptions = {
      ...props,
      className: `mapboxgl-custom-popup ${className ?? ""}`,
    };

    const popup = new mapboxgl.Popup(popupOptions)
      .setDOMContent(container)
      .setMaxWidth("none");

    popup.on("close", handleClose);

    if (marker) {
      const currentPopup = marker.getPopup();
      if (currentPopup) {
        currentPopup.remove();
      }

      marker.setPopup(popup);

      marker.togglePopup();
    } else if (latitude !== undefined && longitude !== undefined) {
      popup.setLngLat([longitude, latitude]).addTo(map);
    }

    return () => {
      popup.off("close", handleClose);
      popup.remove();

      if (marker && marker.getPopup()) {
        marker.setPopup(null);
      }
    };
  }, [
    map,
    marker,
    latitude,
    longitude,
    props,
    className,
    container,
    handleClose,
  ]);

  return createPortal(children, container);
}
Enter fullscreen mode

Exit fullscreen mode



3. Map Controls Component

// components/map/map-controls.tsx

import React from "react";
import { PlusIcon, MinusIcon } from "lucide-react";

import { useMap } from "@/context/map-context";
import { Button } from "../ui/button";

export default function MapCotrols() {
  const { map } = useMap();

  const zoomIn = () => {
    map?.zoomIn();
  };

  const zoomOut = () => {
    map?.zoomOut();
  };

  return (
    
  );
}
Enter fullscreen mode

Exit fullscreen mode



4. Map Styles Component

// components/map/map-styles.tsx

"use client";

import React, { useEffect, useState } from "react";
import {
  MapIcon,
  MoonIcon,
  SatelliteIcon,
  SunIcon,
  TreesIcon,
} from "lucide-react";
import { useTheme } from "next-themes";

import { useMap } from "@/context/map-context";
import { Tabs, TabsList, TabsTrigger } from "../ui/tabs";

type StyleOption = {
  id: string;
  label: string;
  icon: React.ReactNode;
};

const STYLE_OPTIONS: StyleOption[] = [
  {
    id: "streets-v12",
    label: "Map",
    icon: ,
  },
  {
    id: "satellite-streets-v12",
    label: "Satellite",
    icon: ,
  },
  {
    id: "outdoors-v12",
    label: "Terrain",
    icon: ,
  },

  {
    id: "light-v11",
    label: "Light",
    icon: ,
  },
  {
    id: "dark-v11",
    label: "Dark",
    icon: ,
  },
];

export default function MapStyles() {
  const { map } = useMap();
  const { setTheme } = useTheme();
  const [activeStyle, setActiveStyle] = useState("streets-v12");

  const handleChange = (value: string) => {
    if (!map) return;
    map.setStyle(`mapbox://styles/mapbox/${value}`);
    setActiveStyle(value);
  };

  useEffect(() => {
    if (activeStyle === "dark-v11") {
      setTheme("dark");
    } else setTheme("light");
  }, [activeStyle]);

  return (
    
  );
}
Enter fullscreen mode

Exit fullscreen mode



Implementing Search Functionality

Let’s add a search feature that allows users to find locations on the map.



1. Define Location Types and Icons

// lib/mapbox/utils.tsx

import {
  Coffee,
  Utensils,
  ShoppingBag,
  Hotel,
  Dumbbell,
  Landmark,
  Store,
  Banknote,
  GraduationCap,
  Shirt,
  Stethoscope,
  Home,
} from "lucide-react";

export const iconMap: { [key: string]: React.ReactNode } = {
  café: ,
  cafe: ,
  coffee: ,
  restaurant: ,
  food: ,
  hotel: ,
  lodging: ,
  gym: ,
  bank: ,
  shopping: ,
  store: ,
  government: ,
  school: ,
  hospital: ,
  clothing: ,
  home: ,
};

export type LocationSuggestion = {
  mapbox_id: string;
  name: string;
  place_formatted: string;
  maki?: string;
};

export type LocationFeature = {
  type: "Feature";
  geometry: {
    type: "Point";
    coordinates: [number, number];
  };
  properties: {
    name: string;
    name_preferred?: string;
    mapbox_id: string;
    feature_type: string;
    address?: string;
    full_address?: string;
    place_formatted?: string;
    context: {
      country?: {
        name: string;
        country_code: string;
        country_code_alpha_3: string;
      };
      region?: {
        name: string;
        region_code: string;
        region_code_full: string;
      };
      postcode?: { name: string };
      district?: { name: string };
      place?: { name: string };
      locality?: { name: string };
      neighborhood?: { name: string };
      address?: {
        name: string;
        address_number?: string;
        street_name?: string;
      };
      street?: { name: string };
    };
    coordinates: {
      latitude: number;
      longitude: number;
      accuracy?: string;
      routable_points?: {
        name: string;
        latitude: number;
        longitude: number;
        note?: string;
      }[];
    };
    language?: string;
    maki?: string;
    poi_category?: string[];
    poi_category_ids?: string[];
    brand?: string[];
    brand_id?: string[];
    external_ids?: Record;
    metadata?: Record;
    bbox?: [number, number, number, number];
    operational_status?: string;
  };
};
Enter fullscreen mode

Exit fullscreen mode



2. Implement the Search Component

// components/map/map-search.tsx

"use client";

import {
  Command,
  CommandInput,
  CommandList,
  CommandEmpty,
  CommandGroup,
  CommandItem,
} from "@/components/ui/command";
import { Loader2, MapPin, X } from "lucide-react";
import { useState, useEffect } from "react";

import { useDebounce } from "@/hooks/useDebounce";
import { useMap } from "@/context/map-context";
import { cn } from "@/lib/utils";
import {
  iconMap,
  LocationFeature,
  LocationSuggestion,
} from "@/lib/mapbox/utils";
import { LocationMarker } from "../location-marker";
import { LocationPopup } from "../location-popup";

export default function MapSearch() {
  const { map } = useMap();
  const [query, setQuery] = useState("");
  const [displayValue, setDisplayValue] = useState("");
  const [results, setResults] = useState([]);
  const [isSearching, setIsSearching] = useState(false);
  const [isOpen, setIsOpen] = useState(false);
  const [selectedLocation, setSelectedLocation] =
    useState(null);
  const [selectedLocations, setSelectedLocations] = useState(
    []
  );
  const debouncedQuery = useDebounce(query, 400);

  useEffect(() => {
    if (!debouncedQuery.trim()) {
      setResults([]);
      setIsOpen(false);
      return;
    }

    const searchLocations = async () => {
      setIsSearching(true);
      setIsOpen(true);

      try {
        const res = await fetch(
          `https://api.mapbox.com/search/searchbox/v1/suggest?q=${encodeURIComponent(
            debouncedQuery
          )}&access_token=${
            process.env.NEXT_PUBLIC_MAPBOX_TOKEN
          }&session_token=${
            process.env.NEXT_PUBLIC_MAPBOX_SESSION_TOKEN
          }&country=US&limit=5&proximity=-122.4194,37.7749`
        );

        const data = await res.json();
        setResults(data.suggestions ?? []);
      } catch (err) {
        console.error("Geocoding error:", err);
        setResults([]);
      } finally {
        setIsSearching(false);
      }
    };

    searchLocations();
  }, [debouncedQuery]);

  // Handle input change
  const handleInputChange = (value: string) => {
    setQuery(value);
    setDisplayValue(value);
  };

  // Handle location selection
  const handleSelect = async (suggestion: LocationSuggestion) => {
    try {
      setIsSearching(true);

      const res = await fetch(
        `https://api.mapbox.com/search/searchbox/v1/retrieve/${suggestion.mapbox_id}?access_token=${process.env.NEXT_PUBLIC_MAPBOX_TOKEN}&session_token=${process.env.NEXT_PUBLIC_MAPBOX_SESSION_TOKEN}`
      );

      const data = await res.json();
      const featuresData = data?.features;

      if (map && featuresData?.length > 0) {
        const coordinates = featuresData[0]?.geometry?.coordinates;

        map.flyTo({
          center: coordinates,
          zoom: 14,
          speed: 4,
          duration: 1000,
          essential: true,
        });

        setDisplayValue(suggestion.name);

        setSelectedLocations(featuresData);
        setSelectedLocation(featuresData[0]);

        setResults([]);
        setIsOpen(false);
      }
    } catch (err) {
      console.error("Retrieve error:", err);
    } finally {
      setIsSearching(false);
    }
  };

  // Clear search
  const clearSearch = () => {
    setQuery("");
    setDisplayValue("");
    setResults([]);
    setIsOpen(false);
    setSelectedLocation(null);
    setSelectedLocations([]);
  };

  return (
    <>
      

{displayValue && !isSearching && ( )} {isSearching && ( )}

{isOpen && ( {!query.trim() || isSearching ? null : results.length === 0 ? (

No locations found

Try a different search term

) : ( {results.map((location) => ( handleSelect(location)} value={`${location.name} ${location.place_formatted} ${location.mapbox_id}`} className="flex items-center py-3 px-2 cursor-pointer hover:bg-accent rounded-md" >

{location.maki && iconMap[location.maki] ? ( iconMap[location.maki] ) : ( )}

{location.name} {location.place_formatted}

))}
)}
)}
{selectedLocations.map((location) => ( setSelectedLocation(data)} /> ))} {selectedLocation && ( setSelectedLocation(null)} /> )} > ); }
Enter fullscreen mode

Exit fullscreen mode



Creating Location Marker and Popup Components

Let’s create components for displaying location markers and popups on the map.



1. Location Marker Component

// components/location-marker.tsx

import { MapPin } from "lucide-react";

import { LocationFeature } from "@/lib/mapbox/utils";
import Marker from "./map/map-marker";

interface LocationMarkerProps {
  location: LocationFeature;
  onHover: (data: LocationFeature) => void;
}

export function LocationMarker({ location, onHover }: LocationMarkerProps) {
  return (
     {
        onHover(data);
      }}
    >
      
    
  );
}

Enter fullscreen mode

Exit fullscreen mode



2. Location Popup Component

// components/location-popup.tsx

import { LocationFeature, iconMap } from "@/lib/mapbox/utils";
import { cn } from "@/lib/utils";
import {
  LocateIcon,
  MapPin,
  Navigation,
  Star,
  ExternalLink,
} from "lucide-react";

import { Button } from "./ui/button";
import Popup from "./map/map-popup";
import { Badge } from "./ui/badge";
import { Separator } from "./ui/separator";

type LocationPopupProps = {
  location: LocationFeature;
  onClose?: () => void;
};
export function LocationPopup({ location, onClose }: LocationPopupProps) {
  if (!location) return null;

  const { properties, geometry } = location;

  const name = properties?.name || "Unknown Location";
  const address = properties?.full_address || properties?.address || "";
  const categories = properties?.poi_category || [];
  const brand = properties?.brand?.[0] || "";
  const status = properties?.operational_status || "";
  const maki = properties?.maki || "";

  const lat = geometry?.coordinates?.[1] || properties?.coordinates?.latitude;
  const lng = geometry?.coordinates?.[0] || properties?.coordinates?.longitude;

  const getIcon = () => {
    const allKeys = [maki, ...(categories || [])];

    for (const key of allKeys) {
      const lower = key?.toLowerCase();
      if (iconMap[lower]) return iconMap[lower];
    }

    return ;
  };

  return (
    
      

{getIcon()}

{name}

{status && ( {status === "active" ? "Open" : status} )}

{brand && brand !== name && (

{brand}

)} {address && (

{address}

)}
{categories.length > 0 && (

{categories.slice(0, 3).map((category, index) => ( {category} ))} {categories.length > 3 && ( +{categories.length - 3} more )}

)}

{properties?.external_ids?.website && ( )}

ID: {properties?.mapbox_id?.substring(0, 8)}... {lat.toFixed(4)}, {lng.toFixed(4)}

); }
Enter fullscreen mode

Exit fullscreen mode



Styling the Map with Tailwind CSS

Let’s add custom styles for our Mapbox popups using Tailwind CSS. Add these styles to your globals.css file:

/* app/globals.css */

/* Custom Mapbox Popup Styling */
.mapboxgl-custom-popup .mapboxgl-popup-content {
  @apply bg-card text-card-foreground p-5 rounded-lg;
}

.mapboxgl-custom-popup .mapboxgl-popup-close-button {
  font-size: 22px;
  padding: 0 6px;
  right: 0;
  top: 0;
}

.mapboxgl-custom-popup .mapboxgl-popup-close-button:hover {
  background-color: transparent;
}

.mapboxgl-popup-anchor-top .mapboxgl-popup-tip {
  border-bottom-color: var(--card);
  border-top-color: transparent;
  border-left-color: transparent;
  border-right-color: transparent;
}

.mapboxgl-popup-anchor-bottom .mapboxgl-popup-tip {
  border-top-color: var(--card);
  border-bottom-color: transparent;
  border-left-color: transparent;
  border-right-color: transparent;
}

.mapboxgl-popup-anchor-left .mapboxgl-popup-tip {
  border-right-color: var(--card);
  border-top-color: transparent;
  border-bottom-color: transparent;
  border-left-color: transparent;
}

.mapboxgl-popup-anchor-right .mapboxgl-popup-tip {
  border-left-color: var(--card);
  border-top-color: transparent;
  border-bottom-color: transparent;
  border-right-color: transparent;
}


.dark .mapboxgl-popup-anchor-top .mapboxgl-popup-tip {
  border-bottom-color: var(--card);
}
.dark .mapboxgl-popup-anchor-bottom .mapboxgl-popup-tip {
  border-top-color: var(--card);
}
.dark .mapboxgl-popup-anchor-left .mapboxgl-popup-tip {
  border-right-color: var(--card);
}
.dark .mapboxgl-popup-anchor-right .mapboxgl-popup-tip {
  border-left-color: var(--card);
}
Enter fullscreen mode

Exit fullscreen mode



Putting It All Together

Now that we have all the components in place, let’s update our main page to use them:

// app/page.tsx

import { useRef } from "react";

import MapProvider from "@/lib/mapbox/provider";
import MapStyles from "@/components/map/map-styles";
import MapCotrols from "@/components/map/map-controls";
import MapSearch from "@/components/map/map-search";

export default function Home() {
  const mapContainerRef = useRef(null);

  return (
    
  );
}
Enter fullscreen mode

Exit fullscreen mode



Key Features and Benefits

  1. Modular Architecture: The application is built with a modular architecture, making it easy to maintain, extend and scale.
  2. Responsive Design: The UI is fully responsive, working well on both desktop and mobile devices.
  3. Dark Mode Support: The application supports dark mode, with the map style automatically switching to match the theme.
  4. Custom Markers and Popups: We’ve created custom markers and popups that match our application’s design.
  5. Search Functionality: Users can search for locations and see them on the map.
  6. Map Controls: Users can zoom in and out, and switch between different map styles.
  7. Accessibility: The application is built with accessibility in mind, with proper ARIA attributes and keyboard navigation.



Result Preview

Next Map Demo



What You Can Add Next

  • 📦 Marker clustering
  • 📍 User geolocation
  • 🧭 Route directions
  • 📊 Heatmaps or data overlays
  • 🧪 Unit tests for map logic



Final Thoughts

This architecture balances power and clarity. Mapbox handles the interactive magic, while Next.js and shadcn/ui deliver a beautiful, modern developer experience. With a clean separation of logic, reusable components, and context for state, you’re ready to build production-ready mapping tools.



Code

🔗 GitHub Repo



Source link

Leave a Reply

Your email address will not be published. Required fields are marked *