Fix: Mapbox GL JS Not Working — Map Not Rendering, Markers Missing, or Access Token Invalid
Part of: React & Frontend Errors
Quick Answer
How to fix Mapbox GL JS issues — access token setup, React integration with react-map-gl, markers and popups, custom layers, geocoding, directions, and Next.js configuration.
The Problem
The map container is empty:
import mapboxgl from 'mapbox-gl';
mapboxgl.accessToken = 'pk.xxx';
const map = new mapboxgl.Map({
container: 'map',
style: 'mapbox://styles/mapbox/streets-v12',
});
// Empty div — no map tiles visibleOr the access token is rejected:
Error: A valid Mapbox access token is requiredOr markers appear in the wrong position:
Marker shows up in the ocean instead of New York CityWhy This Happens
Mapbox GL JS renders vector maps using WebGL. It downloads style JSON from Mapbox’s API, fetches vector tiles, and rasterizes them on the GPU through a WebGL canvas inside whatever container element you point it at. That entire pipeline assumes three things are true: a valid access token, a sized DOM container that exists at the moment new Map() is called, and a browser with working WebGL. When any of those preconditions fails, you get a silent empty container or a hard error — and which one you get depends on which precondition failed.
The most common failure is the access token. Mapbox requires an API key for every map render and validates it on the first tile request. An expired, deleted, mistyped, or restricted token shows no map and logs a 401 in the network tab — but the error message in the console can be misleadingly vague. Free tier includes 50,000 monthly map loads; once that’s exceeded, the token continues to authenticate but tile requests are rejected with 429s. Tokens are also scoped — a token without the tiles:read scope can sign requests but cannot fetch tiles.
The second failure mode is the container. Mapbox fills its container element using absolute positioning, so a div with no explicit height renders a zero-height map (visually invisible). The container must also exist in the DOM before new mapboxgl.Map() is called — initializing the map against an unmounted element throws or silently no-ops. Coordinate order is the third trap: Mapbox uses [longitude, latitude], the opposite of Google Maps. New York is [-74.006, 40.7128], not [40.7128, -74.006]. Swap them and your marker ends up in Central Asia. Finally, because Mapbox GL JS uses WebGL, it cannot render server-side — there is no WebGL context outside a browser. In Next.js, the map component must be client-only or it will crash the build.
- The access token must be valid, scoped, and within quota.
- The container must have explicit height and exist in the DOM.
- Coordinates are
[longitude, latitude]— the opposite of Google Maps. - Mapbox GL JS is client-only — dynamic import or
'use client'is required.
In Production: Incident Lens
When Mapbox breaks in production, the blast radius is the maps feature — typically a high-engagement surface like a store locator, ride-pickup screen, or delivery tracker. Users see a blank rectangle where the map should be, and because Mapbox failures often don’t throw JavaScript errors, your global error monitoring won’t catch them. Conversion on map-driven flows drops to near zero before anyone notices.
How it surfaces: a quiet uptick in support tickets like “I can’t see the map” or “the map is broken on my phone.” Engineers reproduce locally with a fresh token and see nothing wrong. The real failure is usually one of three things in production: a token that worked in staging has hit its monthly load quota, a CDN or CSP rule introduced last week is blocking api.mapbox.com, or a mobile browser without WebGL2 support is failing silently.
Monitoring signal: instrument the error event on the Mapbox map instance — map.on('error', ...) fires for tile-load failures, style-load failures, and auth errors. Pipe those into your error tracker with a custom tag (source: mapbox) so they don’t drown in the noise. Also track the tile request error rate at the network layer: a sudden spike in 401/429 responses to *.tiles.mapbox.com is a clear production signal. Mapbox itself exposes a Statistics page per token — set up an alert when monthly map loads cross 80% of your tier.
Recovery sequence: first, rotate to a known-good backup token via a runtime config flag — never hardcode the token. Second, check https://status.mapbox.com for ongoing incidents (tile outages are not unheard of). Third, if the issue is quota, request a temporary limit raise from Mapbox while you decide whether to upgrade the plan. The postmortem preventive is twofold: keep a hot backup token in a different Mapbox account with its own billing alert, and add a synthetic check that loads a real Mapbox map in a headless browser every five minutes from a production-equivalent environment.
Fix 1: React Integration with react-map-gl
npm install react-map-gl mapbox-gl'use client';
import Map, { Marker, Popup, NavigationControl, GeolocateControl } from 'react-map-gl';
import 'mapbox-gl/dist/mapbox-gl.css';
import { useState } from 'react';
const MAPBOX_TOKEN = process.env.NEXT_PUBLIC_MAPBOX_TOKEN!;
function MapView() {
const [viewState, setViewState] = useState({
longitude: -74.006,
latitude: 40.7128,
zoom: 12,
});
return (
<div style={{ width: '100%', height: '500px' }}>
<Map
{...viewState}
onMove={(evt) => setViewState(evt.viewState)}
mapboxAccessToken={MAPBOX_TOKEN}
mapStyle="mapbox://styles/mapbox/streets-v12"
// Other map styles:
// 'mapbox://styles/mapbox/dark-v11'
// 'mapbox://styles/mapbox/light-v11'
// 'mapbox://styles/mapbox/satellite-v9'
// 'mapbox://styles/mapbox/satellite-streets-v12'
>
{/* Navigation controls (zoom +/-) */}
<NavigationControl position="top-right" />
{/* User location button */}
<GeolocateControl position="top-right" trackUserLocation />
{/* Marker */}
<Marker longitude={-74.006} latitude={40.7128} anchor="bottom">
<div style={{ fontSize: '24px' }}>Pin</div>
</Marker>
</Map>
</div>
);
}Fix 2: Markers with Popups
'use client';
import Map, { Marker, Popup } from 'react-map-gl';
import 'mapbox-gl/dist/mapbox-gl.css';
import { useState } from 'react';
interface Location {
id: string;
name: string;
description: string;
longitude: number;
latitude: number;
}
const locations: Location[] = [
{ id: '1', name: 'Central Park', description: 'Famous park in Manhattan', longitude: -73.9654, latitude: 40.7829 },
{ id: '2', name: 'Brooklyn Bridge', description: 'Historic bridge', longitude: -73.9969, latitude: 40.7061 },
{ id: '3', name: 'Times Square', description: 'The crossroads of the world', longitude: -73.9855, latitude: 40.7580 },
];
function MapWithPopups() {
const [selectedLocation, setSelectedLocation] = useState<Location | null>(null);
return (
<div style={{ width: '100%', height: '600px' }}>
<Map
initialViewState={{ longitude: -73.98, latitude: 40.75, zoom: 12 }}
mapboxAccessToken={process.env.NEXT_PUBLIC_MAPBOX_TOKEN}
mapStyle="mapbox://styles/mapbox/streets-v12"
>
{locations.map(loc => (
<Marker
key={loc.id}
longitude={loc.longitude}
latitude={loc.latitude}
anchor="bottom"
onClick={(e) => {
e.originalEvent.stopPropagation();
setSelectedLocation(loc);
}}
>
<div style={{
width: '30px', height: '30px', borderRadius: '50%',
background: '#3b82f6', border: '3px solid white',
boxShadow: '0 2px 6px rgba(0,0,0,0.3)', cursor: 'pointer',
}} />
</Marker>
))}
{selectedLocation && (
<Popup
longitude={selectedLocation.longitude}
latitude={selectedLocation.latitude}
anchor="bottom"
onClose={() => setSelectedLocation(null)}
closeOnClick={false}
offset={25}
>
<div style={{ padding: '8px' }}>
<h3 style={{ margin: '0 0 4px', fontWeight: 'bold' }}>{selectedLocation.name}</h3>
<p style={{ margin: 0, fontSize: '14px', color: '#666' }}>{selectedLocation.description}</p>
</div>
</Popup>
)}
</Map>
</div>
);
}Fix 3: Custom Layers and Data Visualization
'use client';
import Map, { Source, Layer } from 'react-map-gl';
import type { CircleLayer, FillLayer, LineLayer } from 'react-map-gl';
import 'mapbox-gl/dist/mapbox-gl.css';
// GeoJSON data
const geojsonData: GeoJSON.FeatureCollection = {
type: 'FeatureCollection',
features: [
{
type: 'Feature',
geometry: { type: 'Point', coordinates: [-74.006, 40.7128] },
properties: { name: 'NYC', magnitude: 8 },
},
{
type: 'Feature',
geometry: { type: 'Point', coordinates: [-118.2437, 34.0522] },
properties: { name: 'LA', magnitude: 5 },
},
],
};
const circleLayer: CircleLayer = {
id: 'points',
type: 'circle',
paint: {
'circle-radius': ['*', ['get', 'magnitude'], 4],
'circle-color': '#3b82f6',
'circle-opacity': 0.7,
'circle-stroke-width': 2,
'circle-stroke-color': '#fff',
},
};
function DataMap() {
return (
<Map
initialViewState={{ longitude: -98, latitude: 39, zoom: 3 }}
mapboxAccessToken={process.env.NEXT_PUBLIC_MAPBOX_TOKEN}
mapStyle="mapbox://styles/mapbox/dark-v11"
style={{ width: '100%', height: '600px' }}
>
<Source id="data" type="geojson" data={geojsonData}>
<Layer {...circleLayer} />
</Source>
</Map>
);
}
// Heatmap layer
const heatmapLayer: HeatmapLayer = {
id: 'heatmap',
type: 'heatmap',
paint: {
'heatmap-weight': ['get', 'magnitude'],
'heatmap-intensity': 1,
'heatmap-radius': 30,
'heatmap-opacity': 0.8,
},
};Fix 4: Geocoding (Search)
'use client';
import Map, { Marker } from 'react-map-gl';
import 'mapbox-gl/dist/mapbox-gl.css';
import { useState } from 'react';
function MapWithSearch() {
const [searchResult, setSearchResult] = useState<{ lng: number; lat: number } | null>(null);
const [query, setQuery] = useState('');
async function handleSearch(e: React.FormEvent) {
e.preventDefault();
if (!query) return;
const res = await fetch(
`https://api.mapbox.com/geocoding/v5/mapbox.places/${encodeURIComponent(query)}.json?access_token=${process.env.NEXT_PUBLIC_MAPBOX_TOKEN}&limit=1`
);
const data = await res.json();
if (data.features?.[0]) {
const [lng, lat] = data.features[0].center;
setSearchResult({ lng, lat });
}
}
return (
<div>
<form onSubmit={handleSearch} className="mb-4">
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search for a place..."
className="border rounded px-3 py-2 mr-2"
/>
<button type="submit" className="bg-blue-500 text-white px-4 py-2 rounded">
Search
</button>
</form>
<div style={{ height: '500px' }}>
<Map
initialViewState={{
longitude: searchResult?.lng || -74.006,
latitude: searchResult?.lat || 40.7128,
zoom: searchResult ? 14 : 10,
}}
mapboxAccessToken={process.env.NEXT_PUBLIC_MAPBOX_TOKEN}
mapStyle="mapbox://styles/mapbox/streets-v12"
>
{searchResult && (
<Marker longitude={searchResult.lng} latitude={searchResult.lat} />
)}
</Map>
</div>
</div>
);
}Fix 5: Next.js Configuration
// Mapbox GL JS is client-only — use dynamic import
import dynamic from 'next/dynamic';
const MapView = dynamic(() => import('@/components/MapView'), {
ssr: false,
loading: () => <div style={{ height: 500, background: '#f0f0f0' }}>Loading map...</div>,
});
export default function Page() {
return <MapView />;
}
// Or use 'use client' directive
// components/MapView.tsx
'use client';
// ... map component with react-map-gl/* Ensure mapbox-gl CSS is loaded */
/* Import in your global CSS or component: */
/* @import 'mapbox-gl/dist/mapbox-gl.css'; */
/* Or in layout.tsx: */
/* import 'mapbox-gl/dist/mapbox-gl.css'; */Fix 6: Fit Bounds to Data
import Map, { Marker, useMap } from 'react-map-gl';
import { useEffect } from 'react';
function FitToMarkers({ locations }: { locations: Location[] }) {
const { current: map } = useMap();
useEffect(() => {
if (!map || locations.length === 0) return;
const bounds = new mapboxgl.LngLatBounds();
locations.forEach(loc => bounds.extend([loc.longitude, loc.latitude]));
map.fitBounds(bounds, { padding: 50, duration: 1000 });
}, [map, locations]);
return (
<>
{locations.map(loc => (
<Marker key={loc.id} longitude={loc.longitude} latitude={loc.latitude} />
))}
</>
);
}Still Not Working?
“A valid Mapbox access token is required” — the token is missing or invalid. Get a token from mapbox.com → Account → Tokens. Use NEXT_PUBLIC_MAPBOX_TOKEN (with NEXT_PUBLIC_ prefix) so it’s available client-side.
Map container is empty with no errors — the container div has zero height. Set style={{ height: '500px' }} on the Map component or its parent. Also import mapbox-gl/dist/mapbox-gl.css — without it, map tiles load but controls and popups are broken.
Markers in the wrong place — Mapbox uses [longitude, latitude] order. New York is longitude: -74.006, latitude: 40.7128. If you swap them, the marker ends up in Central Asia.
Map flickers or re-renders constantly — the initialViewState object is created on every render, causing the map to reset. Use useState to manage view state, or define it outside the component. Never create the initial view state inline in JSX.
401 Unauthorized after deploy — your token has URL restrictions and the production domain isn’t in the allowlist. Open the token in the Mapbox dashboard, add your production origin to URL restrictions, and redeploy. A token that works locally on localhost:3000 will 401 on app.example.com if the restriction list is missing the production host.
Map loads but tiles look stale or wrong — Mapbox caches styles aggressively in the CDN. If you republished a custom style, append a cache-busting query param or wait up to 15 minutes. For tiles, check that your style’s version field is current and that your client isn’t pinning an older mapbox-gl version (map.version returns the runtime version).
mapboxgl is not defined in production but works in dev — your bundler split the mapboxgl worker into a chunk that the CSP blocks. Mapbox GL JS uses a Web Worker for vector tile decoding. Add worker-src 'self' blob: to your CSP and verify the worker URL resolves through your CDN, not directly to api.mapbox.com.
Map renders but pinch-zoom or rotation is dead — on touch devices, gesture handlers compete with the browser’s default pan/zoom. Mapbox handles this automatically when the <Map> element has touch-action: none in CSS, but parent containers with overflow: hidden plus a passive scroll listener can swallow the touch events. Open Chrome DevTools’ Event Listeners tab on the map element and verify Mapbox’s listeners are attached as non-passive.
Tiles load slowly on first paint after deploy — the Mapbox style is fetched fresh on every cold visitor and there is no CDN-side cache hint by default. Pre-render the initial viewport on the server (a static image via Mapbox’s Static Images API for the LCP frame), then hydrate the interactive map afterward. This brings LCP down by 800–1200ms on average and prevents the empty-grey-canvas flash that hurts perceived load time and Core Web Vitals.
For related frontend issues, see Fix: React Three Fiber Not Working, Fix: Next.js Module Not Found Cant Resolve Fs, Fix: Next.js Env Variables Not Working, and Fix: Next.js Hydration Failed.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: i18next Not Working — Translations Missing, Language Not Switching, or Namespace Errors
How to fix i18next issues — react-i18next setup, translation file loading, namespace configuration, language detection, interpolation, pluralization, and Next.js integration.
Fix: React PDF Not Working — PDF Not Rendering, Worker Error, or Pages Blank
How to fix react-pdf and @react-pdf/renderer issues — PDF viewer setup, worker configuration, page rendering, text selection, annotations, and generating PDFs in React.
Fix: Million.js Not Working — Compiler Errors, Components Not Optimized, or React Compatibility Issues
How to fix Million.js issues — compiler setup with Vite and Next.js, block() optimization rules, component compatibility constraints, automatic mode, and debugging performance gains.
Fix: Radix UI Not Working — Popover Not Opening, Dialog Closing Immediately, or Styling Breaking
How to fix Radix UI issues — Popover and Dialog setup, controlled vs uncontrolled state, portal rendering, animation with CSS or Framer Motion, accessibility traps, and Tailwind CSS integration.