The Gray Office Map: the MapLibre Worker That Never Loaded a Tile
Swapped a static map for MapLibre in Next.js 16. The frame rendered, zero tiles loaded, zero errors. The worker never started.
TL;DR
A MapLibre map rendered markers but gray tiles under Next.js 16's Turbopack, which fails to emit the worker's companion file, so zero tile requests fired and the console stayed clean. The fix: load MapLibre 4.7.1 from a CDN, where the worker inlines as a blob URL. Version 6+ instead recommends copying worker files to public and setting workerUrl.
That evening I had just merged the feature branch that replaced the office's static JPEG map with an interactive map component in office-map.tsx. I ran next dev, opened the homepage, and it looked promising. The map frame appeared, the office markers sat where they should.
Two seconds later the smile was gone. The canvas was plain gray. Not a single tile rendered. The console was clean: no errors, no warnings. That is the most treacherous screen in debugging. Everything looks alive except the part that does the work.
Three wrong guesses
My first guess: the tile server was slow, or an ad blocker was in the way. I used the public OpenFreeMap instance, which is free with no limit on views or requests [2]. There is no API key involved, and commercial use is officially allowed. I disabled the blocker, refreshed, still gray.
Second guess: bundling. I switched the component to a dynamic import, hoping a separate chunk would sort it out. It did nothing at all.
Both guesses lost badly to the Network tab. Once I opened it, the truth came out: no tile request ever left the browser. If the server were down, some request would fail or time out. Zero requests means the map library never even asked for data. The problem sat in the map component itself.
The worker that was never born
Context matters here: Next.js 16, released in October 2025, made Turbopack the default bundler for both dev and build [5]. My script was a plain next dev, so the whole workflow had quietly moved to a new bundler.
MapLibre's documentation describes exactly what happened: Turbopack turns new URL('maplibre-gl/dist/maplibre-gl-worker.mjs', import.meta.url) into a hashed asset without emitting its companion file, maplibre-gl-shared.mjs. The worker fails on its first import, and the symptom matches my night perfectly: the map mounts but never requests a single tile [1].
The same symptom class has other shapes. In issue 86495 on the Next.js repo, it is the dev server that kills the MapLibre worker because it does not recognize the worker's HMR ping message, and the console stays just as clean [3]. Different mechanism, similar ending: a half-alive map. The space between new bundlers and web workers is genuinely shaky ground, and it takes many forms.
This is where I understood the difference between a bundled module and a runtime asset. The main thread can build perfectly while the worker file never shows up in the output. MapLibre decodes vector tiles in a worker, not on the main thread. Without the worker, all you get is the DOM markers you built yourself: glow dots from the accent token, a pulse animation that respects prefers-reduced-motion, popups with office names. The frame was there. The house inside never arrived.
For a moment I considered copying the pattern from one of my older projects. The component is identical, and it works fine there. That project still runs Next.js 14 with webpack, which bundles the worker correctly. Same code, different bundler, different result. The variable was never the code.
The iframe detour I called off
Deep in the dead end, a shortcut whispered: swap in a Google Maps iframe embed. Five minutes, done, tiles on screen.
I called it off. The site I was mirroring uses a different pattern, and a Google iframe drags its consent screen and other payloads along, which is unnecessary for showing a couple of office coordinates. Custom markers with the accent-color glow are impossible inside an iframe anyway. Lowering a component's standard because of a bundler bug felt like admitting defeat. Nineteen minutes later, MapLibre was back in the component tree.
That night's fix, and the official path in version 6
The fix that finally worked: load MapLibre 4.7.1 from a CDN, using the non-CSP build. That build inlines its own worker as a blob URL, so worker file resolution stops being the bundler's problem entirely. The loader pattern is simple:
function loadMapLibre() {
if (window.maplibregl) return Promise.resolve(window.maplibregl);
return new Promise((resolve, reject) => {
const script = document.createElement("script");
script.src = "https://unpkg.com/[email protected]/dist/maplibre-gl.js";
script.onload = () => resolve(window.maplibregl);
script.onerror = () => reject(new Error("Failed to load MapLibre"));
document.head.appendChild(script);
});
}
Once the promise resolves, the map initializes with the Liberty style from OpenFreeMap, fitBounds runs over the office coordinates, and popups bind to every marker. The vector tiles finally render. One caveat: if your project runs a strict CSP that rejects blob: in worker-src, this CDN approach will bounce. The docs suggest pointing the worker URL at an explicit same-origin location [1].
The official path has changed going forward. Since version 6.0.0 shipped in July 2026, MapLibre GL JS is ESM-only: the UMD bundles, including the CSP build, are no longer published, and the ESM build loads its worker from a real URL, so worker-src blob: is no longer needed [6]. When I checked, the latest release was 6.11.1, out on September 23, 2026 [4]. For Turbopack users the current recommendation is: copy the worker file and its shared dependency into your public/ folder, then point setWorkerUrl at it [1]. I did not migrate that night. The CDN loader works, and a major-version migration is not a healthy midnight decision.
One habit changed because of this incident: when a component lives half-alive without a single error, I open the Network tab before the Console. Errors usually shout. This kind can only be caught by the request that never left.
Sources: [1] https://maplibre.org/maplibre-gl-js/docs [2] https://openfreemap.org [3] https://github.com/vercel/next.js/issues/86495 [4] https://github.com/maplibre/maplibre-gl-js/releases/tag/v6.11.1 [5] https://nextjs.org/blog/next-16 [6] https://github.com/maplibre/maplibre-gl-js/releases/tag/v6.0.0