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:
- A Mapbox account and API key
- Node.js and npm/yarn installed
- 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
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
Now, let’s install Mapbox GL JS:
npm install mapbox-gl
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
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.
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;
}
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 && (
)}
);
}
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 (
);
}
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);
}
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 (
);
}
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 (
);
}
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;
};
};
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)}
/>
)}
>
);
}
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);
}}
>
);
}
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)}
);
}
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);
}
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 (
);
}
Key Features and Benefits
- Modular Architecture: The application is built with a modular architecture, making it easy to maintain, extend and scale.
- Responsive Design: The UI is fully responsive, working well on both desktop and mobile devices.
- Dark Mode Support: The application supports dark mode, with the map style automatically switching to match the theme.
- Custom Markers and Popups: We’ve created custom markers and popups that match our application’s design.
- Search Functionality: Users can search for locations and see them on the map.
- Map Controls: Users can zoom in and out, and switch between different map styles.
- Accessibility: The application is built with accessibility in mind, with proper ARIA attributes and keyboard navigation.
Result Preview
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.