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<MapContextType | null>(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<HTMLDivElement | null>;
initialViewState: {
longitude: number;
latitude: number;
zoom: number;
};
children?: React.ReactNode;
};
export default function MapProvider({
mapContainerRef,
initialViewState,
children,
}: MapComponentProps) {
const map = useRef<mapboxgl.Map | null>(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 (
<div className="z-[1000]">
<MapContext.Provider value={{ map: map.current! }}>
{children}
</MapContext.Provider>
{!loaded && (
<div className="absolute inset-0 flex items-center justify-center bg-background/80 z-[1000]">
<div className="text-lg font-medium">Loading map...</div>
</div>
)}
</div>
);
}
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<HTMLDivElement | null>(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 (
<div>
<div ref={markerRef}>{children}</div>
</div>
);
}
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 (
<aside className="absolute bottom-8 right-4 z-10 bg-background p-2 rounded-lg shadow-lg flex flex-col gap-2">
<Button variant="ghost" size="icon" onClick={zoomIn}>
<PlusIcon className="w-5 h-5" />
<span className="sr-only">Zoom in</span>
</Button>
<Button variant="ghost" size="icon" onClick={zoomOut}>
<MinusIcon className="w-5 h-5" />
<span className="sr-only">Zoom out</span>
</Button>
</aside>
);
}
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: <MapIcon className="w-5 h-5" />,
},
{
id: "satellite-streets-v12",
label: "Satellite",
icon: <SatelliteIcon className="w-5 h-5" />,
},
{
id: "outdoors-v12",
label: "Terrain",
icon: <TreesIcon className="w-5 h-5" />,
},
{
id: "light-v11",
label: "Light",
icon: <SunIcon className="w-5 h-5" />,
},
{
id: "dark-v11",
label: "Dark",
icon: <MoonIcon className="w-5 h-5" />,
},
];
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 (
<aside className="absolute bottom-4 left-4 z-10">
<Tabs value={activeStyle} onValueChange={handleChange}>
<TabsList className="bg-background shadow-lg">
{STYLE_OPTIONS.map((style) => (
<TabsTrigger
key={style.id}
value={style.id}
className="data-[state=active]:bg-primary data-[state=active]:text-primary-foreground text-sm flex items-center sm:px-3 sm:py-1.5"
>
{style.icon}
<span className="hidden sm:inline">{style.label}</span>
</TabsTrigger>
))}
</TabsList>
</Tabs>
</aside>
);
}
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é: <Coffee className="h-5 w-5" />,
cafe: <Coffee className="h-5 w-5" />,
coffee: <Coffee className="h-5 w-5" />,
restaurant: <Utensils className="h-5 w-5" />,
food: <Utensils className="h-5 w-5" />,
hotel: <Hotel className="h-5 w-5" />,
lodging: <Hotel className="h-5 w-5" />,
gym: <Dumbbell className="h-5 w-5" />,
bank: <Banknote className="h-5 w-5" />,
shopping: <ShoppingBag className="h-5 w-5" />,
store: <Store className="h-5 w-5" />,
government: <Landmark className="h-5 w-5" />,
school: <GraduationCap className="h-5 w-5" />,
hospital: <Stethoscope className="h-5 w-5" />,
clothing: <Shirt className="h-5 w-5" />,
home: <Home className="h-5 w-5" />,
};
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<string, string>;
metadata?: Record<string, unknown>;
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<LocationSuggestion[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const [selectedLocation, setSelectedLocation] =
useState<LocationFeature | null>(null);
const [selectedLocations, setSelectedLocations] = useState<LocationFeature[]>(
[]
);
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 (
<>
<section className="absolute top-4 left-1/2 sm:left-4 z-10 w-[90vw] sm:w-[350px] -translate-x-1/2 sm:translate-x-0 rounded-lg shadow-lg">
<Command className="rounded-lg">
<div
className={cn(
"w-full flex items-center justify-between px-3 gap-1",
isOpen && "border-b"
)}
>
<CommandInput
placeholder="Search locations..."
value={displayValue}
onValueChange={handleInputChange}
className="flex-1"
/>
{displayValue && !isSearching && (
<X
className="size-4 shrink-0 text-muted-foreground cursor-pointer hover:text-foreground transition-colors"
onClick={clearSearch}
/>
)}
{isSearching && (
<Loader2 className="size-4 shrink-0 text-primary animate-spin" />
)}
</div>
{isOpen && (
<CommandList className="max-h-60 overflow-y-auto">
{!query.trim() || isSearching ? null : results.length === 0 ? (
<CommandEmpty className="py-6 text-center">
<div className="flex flex-col items-center justify-center space-y-1">
<p className="text-sm font-medium">No locations found</p>
<p className="text-xs text-muted-foreground">
Try a different search term
</p>
</div>
</CommandEmpty>
) : (
<CommandGroup>
{results.map((location) => (
<CommandItem
key={location.mapbox_id}
onSelect={() => 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"
>
<div className="flex items-center space-x-2">
<div className="bg-primary/10 p-1.5 rounded-full">
{location.maki && iconMap[location.maki] ? (
iconMap[location.maki]
) : (
<MapPin className="h-4 w-4 text-primary" />
)}
</div>
<div className="flex flex-col">
<span className="text-sm font-medium truncate max-w-[270px]">
{location.name}
</span>
<span className="text-xs text-muted-foreground truncate max-w-[270px]">
{location.place_formatted}
</span>
</div>
</div>
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
)}
</Command>
</section>
{selectedLocations.map((location) => (
<LocationMarker
key={location.properties.mapbox_id}
location={location}
onHover={(data) => setSelectedLocation(data)}
/>
))}
{selectedLocation && (
<LocationPopup
location={selectedLocation}
onClose={() => 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 (
<Marker
longitude={location.geometry.coordinates[0]}
latitude={location.geometry.coordinates[1]}
data={location}
onHover={({ data }) => {
onHover(data);
}}
>
<div className="rounded-full flex items-center justify-center transform transition-all duration-200 bg-rose-500 text-white shadow-lg size-8 cursor-pointer hover:scale-110">
<MapPin className="stroke-[2.5px] size-4.5" />
</div>
</Marker>
);
}
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 <LocateIcon className="h-5 w-5" />;
};
return (
<Popup
latitude={lat}
longitude={lng}
onClose={onClose}
offset={15}
closeButton={true}
closeOnClick={false}
className="location-popup"
focusAfterOpen={false}
>
<div className="w-[300px] sm:w-[350px]">
<div className="flex items-start gap-3">
<div className="bg-rose-500/10 p-2 rounded-full shrink-0">
{getIcon()}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between gap-1">
<h3 className="font-medium text-base truncate">{name}</h3>
{status && (
<Badge
variant={status === "active" ? "outline" : "secondary"}
className={cn(
"text-xs",
status === "active" ? "border-green-500 text-green-600" : ""
)}
>
{status === "active" ? "Open" : status}
</Badge>
)}
</div>
{brand && brand !== name && (
<p className="text-sm font-medium text-muted-foreground">
{brand}
</p>
)}
{address && (
<p className="text-sm text-muted-foreground truncate mt-1">
<MapPin className="h-3 w-3 inline mr-1 opacity-70" />
{address}
</p>
)}
</div>
</div>
{categories.length > 0 && (
<div className="mt-3 flex flex-wrap gap-1 max-w-full">
{categories.slice(0, 3).map((category, index) => (
<Badge
key={index}
variant="secondary"
className="text-xs capitalize truncate max-w-[100px]"
>
{category}
</Badge>
))}
{categories.length > 3 && (
<Badge variant="secondary" className="text-xs">
+{categories.length - 3} more
</Badge>
)}
</div>
)}
<Separator className="my-3" />
<div className="grid grid-cols-2 gap-2">
<Button
variant="outline"
size="sm"
className="flex items-center justify-center"
onClick={() => {
window.open(
`https://www.google.com/maps/dir/?api=1&destination=${lat},${lng}`,
"_blank"
);
}}
>
<Navigation className="h-4 w-4 mr-1.5" />
Directions
</Button>
<Button
variant="outline"
size="sm"
className="flex items-center justify-center"
onClick={() => {
console.log("Saved location:", location);
}}
>
<Star className="h-4 w-4 mr-1.5" />
Save
</Button>
{properties?.external_ids?.website && (
<Button
variant="outline"
size="sm"
className="col-span-2 flex items-center justify-center mt-1"
onClick={() => {
window.open(properties.external_ids?.website, "_blank");
}}
>
<ExternalLink className="h-4 w-4 mr-1.5" />
Visit Website
</Button>
)}
</div>
<div className="mt-3 pt-2 border-t text-xs text-muted-foreground">
<div className="flex justify-between items-center">
<span className="truncate max-w-[170px]">
ID: {properties?.mapbox_id?.substring(0, 8)}...
</span>
<span className="text-right">
{lat.toFixed(4)}, {lng.toFixed(4)}
</span>
</div>
</div>
</div>
</Popup>
);
}
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<HTMLDivElement | null>(null);
return (
<div className="w-screen h-screen">
<div
id="map-container"
ref={mapContainerRef}
className="absolute inset-0 h-full w-full"
/>
<MapProvider
mapContainerRef={mapContainerRef}
initialViewState={{
longitude: -122.4194,
latitude: 37.7749,
zoom: 10,
}}
>
<MapSearch />
<MapCotrols />
<MapStyles />
</MapProvider>
</div>
);
}
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.
Top comments (0)