My Web Map Is Blank or the Layer Never Appears

Problem statement

The map loads, the basemap renders, and your data is not there. No error in the console, no failed request in the network tab, nothing.

Web mapping fails silently by design: a missing layer in a tile, an empty tile and a filtered-out feature are all normal conditions, so none of them logs anything. Six distinct causes produce the same blank map, and they need different fixes.

Quick answer

Work down the list β€” each check eliminates a whole class:

map.on("error", (e) => console.error("map error:", e && e.error));

// 1. is the source loading at all?
map.on("sourcedata", (e) => {
  if (e.sourceId === "mydata" && e.isSourceLoaded) {
    console.log("source loaded", map.getSource("mydata"));
  }
});

// 2. is anything in the tiles?
console.log(map.querySourceFeatures("mydata", { sourceLayer: "buildings" }).length);

// 3. is anything rendered?
console.log(map.queryRenderedFeatures({ layers: ["buildings-fill"] }).length);

querySourceFeatures returning zero with successful tile requests means the source-layer name is wrong. Returning features while queryRenderedFeatures returns none means styling or zoom range.

Six causes of a blank web map: wrong CRS, wrong source-layer, zoom range, filter, paint properties and CORS.
Every one of these is silent. The order of checks is what makes them separable.

Step-by-step solution

1. Wrong CRS in the data

GeoJSON must be EPSG:4326. Clients assume it rather than reading it, so a file written in UTM has coordinates like [422200, 5886250] β€” interpreted as longitude 422,200, which is off the world.

print(gdf.crs)
gdf = gdf.to_crs(4326)

The tell is map.fitBounds jumping somewhere absurd, or nothing visible at any zoom.

2. Wrong source-layer

For vector tiles, source-layer must exactly match a layer name inside the MVT. It is not the source name in the style.

from pmtiles.reader import Reader, MmapSource
with open("tiles.pmtiles", "rb") as f:
    print([l["id"] for l in Reader(MmapSource(f)).metadata()["vector_layers"]])

A mismatch means MapLibre fetches tiles successfully, finds no matching layer, and draws nothing. This is the most common cause and produces no error at all.

3. Outside the zoom range

{ id: "buildings", minzoom: 14, maxzoom: 22, ... }

A layer with minzoom: 14 is invisible at zoom 13, correctly and silently. Check both the layer's zoom range and the source's.

A source with the wrong maxzoom also produces 404s past the pyramid β€” visible in the network tab, unlike everything else here.

4. A filter that matches nothing

filter: ["==", ["get", "building"], "yes"]

If the attribute is missing from the tiles, or the value is "Yes", or the property was dropped during generalisation, the filter matches nothing.

const features = map.querySourceFeatures("mydata", { sourceLayer: "buildings" });
console.log(features.length, features[0] && features[0].properties);

Printing the first feature's properties settles it immediately.

5. Paint properties that render nothing

Opacity zero, a colour matching the background, a line width of zero, or a fill on a point layer. All valid, all invisible.

paint: { "fill-opacity": 0.6, "fill-color": "#ff00ff" }

Setting a garish colour and full opacity temporarily separates "not rendering" from "rendering invisibly".

6. CORS or range requests

Cross-origin data needs Access-Control-Allow-Origin, and PMTiles additionally needs Content-Range in Access-Control-Expose-Headers.

A host that returns 200 instead of 206 for a range request has sent the whole archive, and the client cannot use it.

A decision path from source loaded, to features in source, to features rendered, isolating the cause at each step.
Three queries separate six causes: is the source loading, are there features in it, are any rendered.

Code examples

Example 1 β€” a diagnostic that runs in the browser

function diagnose(map, sourceId, layerId, sourceLayer) {
  const problems = [];

  const source = map.getSource(sourceId);
  if (!source) {
    problems.push(`source '${sourceId}' does not exist in the style`);
    return problems;
  }
  console.log("source loaded:", map.isSourceLoaded(sourceId));

  const layer = map.getLayer(layerId);
  if (!layer) {
    problems.push(`layer '${layerId}' does not exist in the style`);
  } else {
    const zoom = map.getZoom();
    const min = layer.minzoom ?? 0;
    const max = layer.maxzoom ?? 24;
    if (zoom < min || zoom > max) {
      problems.push(`zoom ${zoom.toFixed(1)} is outside the layer range ` +
                    `${min}-${max}`);
    }
    const opacity = map.getPaintProperty(layerId, layer.type + "-opacity");
    if (opacity === 0) problems.push("opacity is 0");
  }

  const inSource = map.querySourceFeatures(sourceId,
    sourceLayer ? { sourceLayer } : {});
  console.log(`features in source: ${inSource.length}`);
  if (inSource.length === 0) {
    problems.push("no features in the source β€” check source-layer, the " +
                  "current viewport, and whether tiles are loading");
  } else {
    console.log("first feature properties:", inSource[0].properties);
  }

  const rendered = map.queryRenderedFeatures({ layers: [layerId] });
  console.log(`features rendered: ${rendered.length}`);
  if (inSource.length > 0 && rendered.length === 0) {
    problems.push("features exist but none render β€” check the filter, " +
                  "the zoom range and the paint properties");
  }

  problems.forEach((p) => console.warn("!", p));
  return problems;
}

The distinction between querySourceFeatures and queryRenderedFeatures is what makes this work. The first asks "is the data here"; the second asks "is any of it drawn".

Example 2 β€” checking the data before it reaches the browser

import json
import geopandas as gpd


def check_web_layer(path, expect_columns=()):
    """Everything a browser will silently reject."""
    gdf = gpd.read_file(path)
    problems = []

    if gdf.crs is None:
        problems.append("no CRS set")
    elif gdf.crs.to_epsg() != 4326:
        problems.append(f"CRS is {gdf.crs.to_string()}, must be EPSG:4326")

    bounds = gdf.total_bounds
    if not (-180 <= bounds[0] <= 180 and -90 <= bounds[1] <= 90):
        problems.append(f"bounds {bounds} are not longitude/latitude β€” "
                        "the data is probably projected")

    empty = int((gdf.geometry.isna() | gdf.geometry.is_empty).sum())
    if empty:
        problems.append(f"{empty:,} null or empty geometries β€” many clients "
                        "throw on these")
    invalid = int((~gdf.geometry.is_valid).sum())
    if invalid:
        problems.append(f"{invalid:,} invalid geometries")

    for column in expect_columns:
        if column not in gdf.columns:
            problems.append(f"expected attribute '{column}' is missing")
        elif gdf[column].isna().all():
            problems.append(f"attribute '{column}' is entirely null")

    print(f"  {len(gdf):,} features, bounds "
          f"{[round(b, 4) for b in bounds]}")
    for issue in problems:
        print(f"  ! {issue}")
    return problems

Checking the bounds against the longitude and latitude ranges catches the projected-CRS case even when the CRS metadata is missing or wrong, which is the situation that produces the most confusing blank maps.

Example 3 β€” verifying the tiles themselves

import gzip
import mapbox_vector_tile as mvt
import requests


def check_tile(url_template, z, x, y):
    """Fetch one tile and report what is actually inside it."""
    url = url_template.format(z=z, x=x, y=y)
    response = requests.get(url, timeout=30)
    print(f"  {url} -> HTTP {response.status_code}, "
          f"{len(response.content)} bytes")

    if response.status_code != 200:
        print("  ! tile request failed β€” check the URL template and the "
              "zoom range")
        return None

    payload = response.content
    if payload[:2] == b"\x1f\x8b":
        payload = gzip.decompress(payload)
        print("  tile was gzipped")

    try:
        decoded = mvt.decode(payload)
    except Exception as exc:
        print(f"  ! not a valid vector tile: {type(exc).__name__}: {exc}")
        return None

    if not decoded:
        print("  ! tile decoded but contains no layers")
    for name, layer in decoded.items():
        fields = set()
        for feature in layer["features"][:20]:
            fields.update(feature.get("properties", {}))
        print(f"  layer '{name}': {len(layer['features'])} features, "
              f"fields {sorted(fields)}")
    return decoded

This is the step that resolves the source-layer question definitively. The layer names printed here are exactly what the style must use.

Explanation

Why everything fails silently

Web map rendering treats absence as normal. A tile with no features is normal over the sea. A layer missing from a tile is normal where that layer has no data. A feature filtered out is the filter working.

So none of these can raise an error without producing false alarms on every ordinary map. The cost is that a genuine misconfiguration is indistinguishable from an empty area.

The defence is the query API: querySourceFeatures and queryRenderedFeatures tell you what the renderer actually has, which no amount of staring at the style will.

Why the CRS mistake is so common

Desktop GIS reprojects on the fly. A UTM layer and a WGS 84 layer overlay correctly in QGIS, so the CRS never comes up.

GeoJSON has no CRS field in current practice β€” the specification fixes it as WGS 84. Clients therefore do not check, and a projected GeoJSON is not an error, just coordinates in the wrong place.

The bounds check is the reliable test: longitude outside Β±180 or latitude outside Β±90 means the data is projected.

Why source-layer is a separate concept

A vector tile source can contain many layers, and one style layer draws one of them. So the style needs to name both the source and the layer within it.

The names are independent: a source called mydata can contain layers called buildings and roads. There is no default and no fallback.

Because an absent layer is a normal condition, a typo produces silence. Reading vector_layers from the archive metadata is the only reliable way to get the name right.

Why to check rendering separately from data

The two failure classes need opposite fixes.

If querySourceFeatures returns zero, the data is not arriving: wrong URL, wrong source-layer, tiles not generated for this area or zoom, or a CORS failure.

If it returns features and queryRenderedFeatures returns none, the data is there and the style is hiding it: a filter, a zoom range, zero opacity, or a colour matching the background.

Confusing the two sends people to rebuild tiles when the problem is a filter expression.

querySourceFeatures ignoring filters and zoom against queryRenderedFeatures respecting both.
Confusing the two sends people to rebuild tiles when the problem is a filter expression.

Edge cases or notes

  • GeoJSON must be EPSG:4326. Check the bounds, not just the metadata.
  • source-layer is the layer inside the tile, not the source name.
  • Attach map.on("error", ...) β€” MapLibre is otherwise silent.
  • querySourceFeatures and queryRenderedFeatures answer different questions.
  • Null and empty geometries make many clients throw or skip.
  • A layer's minzoom hides it silently below that zoom.
  • Cross-origin PMTiles needs Content-Range exposed in CORS.
  • Test with a garish colour and full opacity to rule out paint properties.

FAQ

Why is my web map blank?

Six common causes: a projected CRS, a wrong source-layer, being outside the layer's zoom range, a filter matching nothing, paint properties rendering nothing, or a CORS failure. All are silent.

How do I tell whether the data is loading?

map.querySourceFeatures(sourceId, { sourceLayer }). Zero features with successful tile requests means the source-layer name is wrong.

What is the difference between querySourceFeatures and queryRenderedFeatures?

The first asks what is in the source; the second asks what is actually drawn. Features in one and not the other means the style is hiding them.

Why does my GeoJSON not appear?

Usually a projected CRS. Check total_bounds against Β±180 and Β±90 β€” anything outside means the data is not longitude and latitude.

How do I find the right source-layer name?

Read vector_layers from the archive metadata, or decode one tile and print its layer names.

Why does it work locally and not when deployed?

CORS. Cross-origin PMTiles needs Access-Control-Allow-Origin and Content-Range in Access-Control-Expose-Headers.

Why does nothing render even though features are in the source?

A filter matching nothing, a zoom range excluding the current view, or paint properties that draw nothing. Set a garish colour at full opacity to check.