Both maps in Letterway rendered as an empty rectangle for weeks. No error in the console, no failed request in the network tab, no exception anywhere. Just a box in the page's background colour where a map should be.
The cause was eight lines inside maplibre-gl's distributed bundle, and it has nothing to do with your map style, your container height, or your tile provider. If you are here from a search, the fix is at the bottom and it is four lines. The rest is how to be certain that is your bug and not something that merely looks like it.
The symptom
MapLibre GL JS in a Next.js app. The map "works" — no crash, no red text — and draws nothing but a flat rectangle. Specifically:
map.on('error')fires nothing, or fires only about tiles- the container has a real height, and the canvas has real pixel dimensions
maplibre-gl/dist/maplibre-gl.cssis imported and applied- the style validates, and
map.getStyle().layerslists every layer you declared map.on('load')never fires
That last one is the tell, and it is the one that makes the bug expensive. Nearly every MapLibre tutorial adds sources and layers inside map.on('load', …). If load never comes, everything you meant to draw is skipped, silently, and what remains is whatever your background layer painted. Which in a well-designed style is a colour very close to your page.
Two things that are not the cause
Before the real one, because these are what you will find first.
It is not the container height. MapLibre measures its container once at construction, and inside a flex or aspect-ratio parent that height can genuinely still be 0. It is a real bug and worth guarding with a ResizeObserver that calls map.resize(). It was not this. The canvas had correct dimensions the whole time.
It is not the tile host. We use OpenFreeMap, which is donation-funded and has no SLA, so it was the obvious suspect and we blamed it twice. It was healthy: the TileJSON at https://tiles.openfreemap.org/planet returned a valid TileJSON 3.0.0 with sixteen vector_layers and live tile URLs.
The measurement that found it
We reproduced the component in a minimal Next.js app with the tile network stubbed out entirely, then asked the map instance what it thought was going on:
{
center: { lng: 67.01, lat: 24.861 }, // correct
zoom: 3.4, // correct
styleLoaded: false,
worldSourceLoaded: false,
layerIds: ['ground','land','sea','coast','borders', …], // all 16 present
rendered: 0
}
Every layer declared. Every source declared. Not one source loaded. Including a geojson source whose data was a plain JavaScript object already in memory — no network involved at all.
A geojson source that cannot load from an object in memory is not a network problem. It is a parsing problem, and MapLibre parses off the main thread. So: the worker.
Chrome confirmed it in one line. With Playwright listening for worker creation:
WORKER created: http://localhost:8103/
That is the page's own URL. The browser had been handed an empty worker URL, resolved it against the document, fetched the app's own HTML, and tried to execute it as JavaScript. The worker died on the first <, and it did so without an uncaught error the page could see.
The eight lines
Here is how MapLibre v6 finds its worker, taken verbatim from dist/maplibre-gl.mjs (minified names, comments ours):
function bi() { // getWorkerUrl
let e = import.meta.url;
if (!/^https?:/.test(e)) return ``; // ← here
let t = e.endsWith(`-dev.mjs`)
? `maplibre-gl-worker-dev.mjs`
: `maplibre-gl-worker.mjs`;
return new URL(`./${t}`, e).href;
}
async function wi() { // createWorker
let e = k.WORKER_URL || bi(); // → ''
let t = !e?.endsWith(`.cjs`); // → true
if (!yi(e)) return xi(e, t); // not cross-origin → straight through
…
}
function xi(e, t) { // new Worker
if (t) try { return new Worker(e, { type: `module` }) } catch (e) { … }
return new Worker(e);
}
MapLibre resolves its worker relative to import.meta.url, which is correct when the library is loaded as ESM from a URL. Bundlers rewrite import.meta.url to a build-time file:// path. Here it is in our own compiled output, from .next/static/chunks/:
WORKER_URL||function(){
let t="file:///tmp/nx/node_modules/maplibre-gl/dist/maplibre-gl.mjs";
if(!/^https?:/.test(t))return"";
…
file: does not match /^https?:/. The function returns ''. '' is falsy but it is returned, not thrown, so WORKER_URL || bi() yields '' and nothing downstream checks it. new Worker('') is not an error in any browser — an empty URL resolves to the current document, so it is a perfectly legitimate request for a page that happens not to be JavaScript.
Every layer of that chain does something defensible. The result is a map that draws nothing and says nothing.
Why it looks like a styling bug
Because the only part of MapLibre that still works is the part that does not need the worker.
The background layer is painted on the main thread from a colour in the style, so it renders. Everything else — vector tiles, GeoJSON, glyphs, sprites — is parsed in the worker, so none of it does. You get a correctly sized canvas, filled with exactly the colour you specified, inside a container you have measured three times. The first four hours go into CSS.
The fix
Give MapLibre a real same-origin URL for its worker.
1. Copy the worker into your public folder at build time. Both files, and into the same directory — the worker is a module worker and imports ./maplibre-gl-shared.mjs relative to its own URL:
// scripts/copy-maplibre-worker.mjs
import { copyFileSync, mkdirSync } from 'node:fs';
import { createRequire } from 'node:module';
import { dirname, join } from 'node:path';
// resolve('maplibre-gl') throws — the package has an exports map with no main
// entry. Resolve its package.json instead, which is always exported.
const dist = join(
dirname(createRequire(import.meta.url).resolve('maplibre-gl/package.json')),
'dist',
);
const out = join(process.cwd(), 'public', 'maplibre');
mkdirSync(out, { recursive: true });
for (const f of ['maplibre-gl-worker.mjs', 'maplibre-gl-shared.mjs']) {
copyFileSync(join(dist, f), join(out, f));
}
2. Run it before every build and every dev server. npm lifecycle hooks do this for free:
"scripts": {
"predev": "node scripts/copy-maplibre-worker.mjs",
"prebuild": "node scripts/copy-maplibre-worker.mjs"
}
Keep public/maplibre out of git. It is a copy of a dependency, and a committed copy will drift from the installed version.
3. Tell MapLibre where it is, before you construct a map:
import { setWorkerUrl } from 'maplibre-gl';
let done = false;
export function ensureMapLibreWorker() {
if (done || typeof window === 'undefined') return;
setWorkerUrl(new URL('/maplibre/maplibre-gl-worker.mjs', window.location.origin).href);
done = true;
}
Call it at the top of the effect that builds the map. setWorkerUrl sets WORKER_URL, which short-circuits the broken resolution entirely.
You will know immediately:
WORKER: http://localhost:8104/maplibre/maplibre-gl-worker.mjs
{ styleLoaded: true, worldLoaded: true, renderedLand: 3 }
Versions
Reproduced on maplibre-gl 6.3.0 with Next.js 15.5.23 (webpack build) and observed identically in Next.js 16.2.9 with Turbopack. The mechanism is not specific to either bundler, or to Next: any tool that rewrites import.meta.url at build time to something that is not http(s): produces it. If your map is blank under Vite, Rspack or Parcel, check the same thing.
Diagnose it in one line, whatever your stack:
console.log(map.getStyle().layers.length, map.isSourceLoaded('<your-source>'));
Layers present and no source loaded is this bug. Layers missing is a style problem, and that is a different afternoon.
The habit worth changing
The fix above stops the bleeding. The reason it bled for weeks is separate, and it is the more useful lesson.
Everything we drew — the route arc, the endpoint markers, the country dots — was added inside map.on('load', …), because that is what every example does. MapLibre withholds load until the style's sources have resolved. So a single unresolved source took out every feature in the app, not just the one that failed.
Sources and layers can be declared in the style object you pass to the constructor. It is applied synchronously, so there is no event to wait for and nothing to skip:
const style = myStyle();
style.sources.route = { type: 'geojson', data: routeGeoJSON };
style.layers.push({ id: 'route-line', type: 'line', source: 'route', paint: { … } });
const map = new MapLibreMap({ container, style, … });
For the handful of things that genuinely must happen after the style is applied — registering an image, updating a moving marker — use styledata rather than load. It fires when the style is applied, whether or not a remote source ever answers.
We went further, because a map that depends on a third party for its existence is a map with somebody else's uptime. Natural Earth 1:110m land and borders are now bundled with the app — about 115 kB of GeoJSON, rounded to two decimals — and declared as plain geojson sources. Vector tiles still load on top and add rivers, roads, parks and place names above zoom 7. But they are detail, not structure. With the network cut off entirely, the map still draws land, sea, coastlines and national borders.
That is the shape of the rule: when a library gives you a "ready" event, ask what it is waiting for. If any part of that answer is a third party, your feature now has that third party's uptime — and it will fail as silence, not as an error.