Custom Vector Tiles in Mapbox GL JS: When the Layer Loads but Nothing Draws
A custom vector source in Mapbox GL JS can fail silently. Here is what actually has to match — tile format, source-layer naming, and CORS — plus a worked example and verification checks.
21 Oct 2025, 07:36 UTC

The map loads, the layer doesn't
You point a Mapbox GL JS vector source at your own tile endpoint, add a circle layer, and reload. The basemap renders. Your layer does not. The console is quiet. That silence is the real problem: GL JS will accept a source whose tiles it can never draw, and a layer whose source-layer name matches nothing simply paints zero features without complaint.
The thesis here: when a custom vector layer fails to appear, the cause is almost always one of three mismatches — tile format and projection, source-layer naming, or cross-origin delivery. None of them are style syntax errors. Debug those three before you touch a single paint property.
What a custom vector source actually is
Mapbox GL JS is not limited to Mapbox-hosted tiles. A source of type: 'vector' takes either a tiles array of URL templates or a url pointing at a TileJSON document. Each fetched tile is a Mapbox Vector Tile (MVT): a protobuf containing geometry, feature attributes, and one or more named layers. The renderer decodes it in the browser and draws it with WebGL.
Two constraints follow, and neither is negotiable:
- Format: the payload must be MVT, commonly served as
.pbfor.mvt. A GeoJSON file behind a{z}/{x}/{y}URL will not work. - Projection: tiles must be in Web Mercator (EPSG:3857) on the standard XYZ grid. A dataset published in a national grid will be misplaced or invisible.
The three things that must line up
1. source-layer must match the tile's internal layer name
This is the most common silent failure. A vector tile can contain several named layers, and the style layer selects exactly one by string. If your tile contains roads and you write 'source-layer': 'road', nothing renders and typically no error is raised. Get the real name from your tile generator's configuration, or decode a tile and inspect it.
2. HTTPS and CORS
Browsers block cross-origin tile requests unless the response carries an Access-Control-Allow-Origin header covering the page's origin. Serve tiles over HTTPS and set that header on the tile responses themselves, not just on the HTML page. A missing header appears as blocked requests in the network panel rather than as a map error.
3. Zoom range
minzoom and maxzoom on the source describe the tile pyramid, not the map's zoom. If your generator only produces tiles to z14 and the source claims z18, GL JS will request tiles that do not exist and will have nothing to overzoom.
Worked example
Run this inside a page that already initializes a map. Replace the host, the layer name, and the attribute with your own values.
map.on('load', () => {
map.addSource('custom', {
type: 'vector',
tiles: ['https://tiles.example.com/points/{z}/{x}/{y}.pbf'],
minzoom: 0,
maxzoom: 14
});
map.addLayer({
id: 'points',
type: 'circle',
source: 'custom',
'source-layer': 'points', // must exist inside the tile
filter: ['>=', ['get', 'magnitude'], 3],
paint: {
'circle-radius': ['interpolate', ['linear'], ['zoom'], 4, 3, 12, 10],
'circle-color': '#1f6feb'
}
});
});
Placeholders: tiles.example.com is your tile host, points is both the source id and (in this example) the tile layer name, and magnitude is a numeric attribute present on your features. If magnitude is absent from a feature, the filter comparison evaluates false and that feature is dropped — a convenient way to hide low-value points, and a confusing one if you did not expect it.
Vector or raster: the trade-off
| Concern | Custom vector tiles | Raster tiles |
|---|---|---|
| Styling | Full client-side control; restyle without re-tiling | Baked into the image |
| Bandwidth | Usually smaller, especially for sparse data | Larger and roughly constant per tile |
| Zooming | Geometry stays sharp past source zoom | Blurs past source resolution |
| Build effort | You must know and maintain the tile schema | Serve images and stop |
| Client cost | WebGL shader work at startup | Cheaper to draw |
The client cost is the part people underestimate. Vector rendering pushes work onto the device, so on low-end hardware the first paint can be noticeably slower than a raster equivalent. Test on the hardware your users actually have, not only your development machine.
How to check it worked
Open the page in a modern browser and run these in the console:
map.getSource('custom').type; // expect 'vector'
map.getLayer('points'); // expect the layer definition, including its filter
map.getStyle().sources.custom.tiles;
Then use the network panel: confirm requests go to tiles.example.com, return a 200, and are not blocked by CORS. Finally, pan and zoom with the performance panel open and watch frame timing. That is the only honest test of whether the vector approach is fast enough for your case.
Version assumptions and limits
This describes the stable addSource/addLayer API present across Mapbox GL JS v2 and v3 and style spec v8. Note that GL JS v2 and later require an access token at initialization even when every source is your own; if that is a problem, MapLibre GL JS is a compatible fork without the requirement. Newer style features may depend on WebGL capabilities that older devices lack, so treat browser support as something to verify rather than assume. Check this against the GL JS version you actually ship.
The actionable version: when a custom vector layer is invisible, check source-layer first, CORS second, zoom range third. Style syntax is rarely the culprit.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.