Skip to content

Adding Layers

Your data layers live in your project's layers.json, in its layers list. The app reads it as settings.layers. It's a separate file only to keep settings.json readable.

Each entry is an object, and its type picks the loader:

{
  "$schema": "../../schema/layers.schema.json",
  "layers": [
    {
      "id": "parks",
      "type": "geojson",
      "name": "National Parks",
      "geometryType": "polygon",
      "url": "./layers/parks.geojson",
      "symbology": { "kind": "simple", "color": "#2c8a3e" }
    }
  ]
}

The examples below show one layer each, an entry in that list.

Array order sets both the layer-list order and the draw order: the first layer draws on top. Raster layers (Esri dynamic and tiled, and "raster": true PMTiles and tile servers) are the exception and always draw below every vector layer. A group appears where its group name first shows up in the array, so a layer can sit anywhere in the array for draw order and still display inside its group.

Common fields (every layer type)

Field Type Default Description
id string required Unique across all layers
name string required Name in the layer list and legend
type "geojson" \| "pmtiles" \| "tiles" \| "flatgeobuf" \| "esri" \| "geoparquet" required Picks the loader
symbology object none How to draw it, including its opacity (see Symbology). Required for most types; optional for PMTiles and Esri
group string none Groups layers under one header in the layer list. Same name, same group
visible boolean true Visible at startup
minZoom / maxZoom number MapLibre defaults Zooms at which the layer draws. Separate from the camera limits in Map & Camera
geometryType "point" \| "line" \| "polygon" none Sets the legend swatch shape and which geometries draw. Leave it out for mixed geometry. PMTiles detects it when the archive records it
label object none Text labels (see Labels)
popup object or false none (off) Feature popups (see Popups)
hideFromLegend boolean false Keep the layer on the map and in the layer list, but out of the legend. Works with any symbology
legend object from the symbology Choose what the legend shows for this layer (see Custom legend). The only way to get a raster or a service-styled Esri layer into the legend
actions object: opacity, labels, zoomToExtent, filter, export (each boolean) global default Override which items this layer's row menu shows
filters list none Columns users can filter by in the layer list (see Filtering). geojson, geoparquet and Esri feature layers only

Opacity belongs to the symbology (opacity and strokeOpacity, see Symbology), not the layer. A top-level opacity on a layer is an unknown key, reported when the project's files are read. Layers without kit symbology are the exception: Esri's esriOpacity and raster tiles' rasterOpacity set their starting opacity. The layer list's Adjust Transparency multiplies on top of either.

A layer without geometryType is treated as mixed: it draws polygon fills, lines and points, but no polygon outlines. Outlines only draw on layers known to be polygons, and only when the symbology sets a strokeWidth.

Where files live

  • Local files go in your project's layers/. Reference them as "./layers/<file>"; Vite resolves the path at build time. Supported extensions: .geojson, .pmtiles, .fgb, .parquet and .geoparquet.
  • Remote URLs (http:// or https://) are used as they are. The server must allow cross-origin requests (CORS).

GeoJSON file size

A geojson layer loads and parses the whole file in memory on the main thread; nothing streams. Roughly: under 5 MB is fine, 5 to 20 MB adds noticeable startup lag, and past about 50 MB it isn't usable. minZoom only controls drawing, not parsing. For large data, use PMTiles or FlatGeobuf with "bbox": "dynamic".

geojson

The simplest type. One file becomes fill, line and point sublayers split by geometry, so a single file can mix points, lines and polygons.

Field Type Description
url string Path to a .geojson file (./layers/…) or a remote URL
duckdb boolean, or an object with mvt, maxTileFeatures, maxTileZoom Read the file with DuckDB instead of MapLibre. Default false
{
  "id": "airports",
  "type": "geojson",
  "name": "US Major Airports",
  "geometryType": "point",
  "url": "https://example.com/airports.geojson",
  "symbology": { "kind": "ref", "name": "airports-by-passengers" }
}

By default MapLibre reads the file itself. "duckdb": true reads it with DuckDB instead. That isn't faster, and it downloads DuckDB at startup, so only turn it on when you want DuckDB features: SQL queries (coming with a SQL widget), or { "mvt": true } to draw a large file as tiles cut on the fly, with the same maxTileFeatures and maxTileZoom options as GeoParquet. The demo's power-plants layer uses "duckdb": true.

pmtiles

PMTiles vector tiles: a single archive file that streams only the tiles in view. The best choice for large datasets. An archive can hold several layers of its own (source layers); tippecanoe names them with -l.

Field Type Description
url string Path to a .pmtiles archive, local or remote
sourceLayers list Which source layers to draw, in draw order (first on top). Leave it out (or []) to draw every source layer in the archive
symbology object Default style for source layers without their own. Leave it out for automatic colors
collapsed boolean Whether the expandable layer-list row starts collapsed. Default true. Only used with two or more source layers
encoding "mvt" \| "mlt" Tile format. Set "mlt" for a MapLibre Tiles archive. Default "mvt"
raster boolean true for an archive of images; see Raster PMTiles

How it shows depends on how many source layers it draws:

  • One (a one-entry sourceLayers, or an archive that only has one): a normal layer, named by the layer's name.
  • Two or more (listed, or every layer in the archive): an expandable row in the Layer List, with one child row per source layer, and a block in the Legend. They all draw from one source, so tiles are only fetched once.

Source layers

Each sourceLayers entry names a source layer and can override anything for it. Fields it leaves out fall back to the archive layer's own (symbology, label, popup, visible, minZoom, maxZoom), then to defaults. legend is the exception: a source layer only uses its own, since the archive's would repeat under every source layer.

Field Type Description
sourceLayer string Name of the layer inside the archive (required)
name string Name in the layer list and legend. Default: sourceLayer
symbology object Its style
geometryType "point" \| "line" \| "polygon" Leave it out to detect it, or to draw mixed geometry
legend object What the legend shows for this source layer (see Custom legend)
label, popup, visible, minZoom, maxZoom Same as the common fields
{
  "id": "counties",
  "type": "pmtiles",
  "name": "US Counties",
  "url": "./layers/US_counties.pmtiles",
  "sourceLayers": [{ "sourceLayer": "US_counties" }],
  "symbology": { "kind": "simple", "color": "#838291", "opacity": 0.4, "strokeColor": "#fff", "strokeWidth": 1 }
}

A whole basemap-style archive, styling some of its layers and leaving others out:

{
  "id": "protomaps-styled",
  "type": "pmtiles",
  "name": "Protomaps",
  "url": "https://data.source.coop/protomaps/openstreetmap/v4.pmtiles",
  "popup": { "displayType": "rich" },
  "sourceLayers": [
    {
      "sourceLayer": "places",
      "name": "Places",
      "geometryType": "point",
      "symbology": { "kind": "simple", "color": "#1f2937", "radius": 3 },
      "label": { "field": "name" }
    },
    {
      "sourceLayer": "roads",
      "name": "Roads",
      "geometryType": "line",
      "symbology": { "kind": "simple", "color": "#f59e0b", "width": 1.5 }
    },
    {
      "sourceLayer": "water",
      "name": "Water",
      "symbology": { "kind": "simple", "color": "#3b82f6", "opacity": 0.6 }
    },
    {
      "sourceLayer": "landuse",
      "name": "Land Use",
      "geometryType": "polygon",
      "symbology": { "kind": "simple", "color": "#22c55e", "opacity": 0.25 }
    }
  ]
}

The demo has both forms: protomaps-plain draws every source layer with automatic colors, and protomaps-styled lists eight and leaves out earth.

Use a host that allows your site

A remote archive needs a host that allows requests from your site (CORS). The demo uses Source Cooperative's copy of the Protomaps planet, which allows any site. Protomaps' own daily build at latest.protomaps.com only allows http://localhost:5173, so it works in npm run dev but fails in npm run preview and on any deployed site.

Defaults and draw order

  • Automatic colors. A source layer with no symbology (its own or the archive's) gets the next color from a fixed palette, in order, so colors stay the same between visits. Automatic fills are 40% opaque so overlapping polygons don't hide each other; lines and points stay solid.
  • Draw order. Every source layer's polygons draw first, then all lines, then all points, then all labels, which keeps points and lines above polygons. Within each of those, the sourceLayers order applies (first on top), or the archive's own order when nothing is listed. For a basemap-style archive, list the source layers so large polygons like land and water sit below the rest, or leave them out.
  • Zooms. When the archive says a source layer only starts at a certain zoom (Protomaps' buildings start at 11), that becomes its minimum zoom, unless the entry sets its own. The archive's maximum zoom isn't applied, because MapLibre keeps drawing past the last zoom the archive has tiles for.
  • Geometry. Archives built with tippecanoe record each source layer's geometry. When geometryType is left out, the kit reads it from there and styles the layer as if it were declared. Without that record (Planetiler archives like Protomaps don't have one), the source layer is drawn as mixed geometry.
  • Mistakes. A listed source layer the archive doesn't have logs a warning with the valid names and is skipped. Listing one twice skips the whole layer with a console warning that explains the fix. The old sourceLayer field that sourceLayers replaced is an unknown key, reported when the project's files are read. An MLT archive without "encoding": "mlt", or a raster flag that doesn't match the archive's tile type, logs a console warning.

Raster PMTiles

An archive of images (PNG, JPEG, WebP or AVIF tiles: imagery, scanned maps, hillshade) takes "raster": true:

{
  "id": "flowers-raster",
  "type": "pmtiles",
  "raster": true,
  "name": "Flowers Imagery",
  "url": "https://air.mtn.tw/flowers.pmtiles"
}
Field Type Default Description
raster true Marks the tiles as images
rasterOpacity number 1 Starting opacity, 0 to 1
tileSize 256 \| 512 512 Tile size in pixels. The archive doesn't record it, and a wrong value draws the imagery at half or double scale

A raster archive draws below every vector layer and stays sharp past its last zoom, because MapLibre keeps scaling the last tiles up. It supports visibility, transparency and Zoom to Extent only: there's no symbology, popup or legend entry. The demo's flowers-raster layer is one.

tiles

Tiles served one at a time by a tile server, such as Martin, pg_tileserv, GeoServer, MapTiler or OpenFreeMap. url is either a TileJSON URL or a bare {z}/{x}/{y} template. URLs are used as they are, so the server must allow cross-origin requests; local tile files belong in PMTiles.

A TileJSON URL describes itself (source layers, zoom range, attribution), so it needs nothing else:

{
  "id": "demotiles-tilejson",
  "type": "tiles",
  "name": "MapLibre Demo Tiles",
  "url": "https://demotiles.maplibre.org/tiles/tiles.json"
}

A template has no metadata, so it must list its sourceLayers (without them the layer is skipped, with a console warning) and should give the zoom range the server has:

{
  "id": "demotiles-xyz",
  "type": "tiles",
  "name": "Countries",
  "url": "https://demotiles.maplibre.org/tiles/{z}/{x}/{y}.pbf",
  "maxTileZoom": 6,
  "sourceLayers": [
    {
      "sourceLayer": "countries",
      "geometryType": "polygon",
      "symbology": { "kind": "simple", "color": "#f472b6", "opacity": 0.3 }
    }
  ]
}
Field Type Default Description
url string required A TileJSON URL, or a template with {z}, {x} and {y}
minTileZoom number 0 Templates only: lowest zoom the server has tiles for
maxTileZoom number 14 Templates only: highest zoom the server has tiles for. Past it MapLibre scales the last tiles up instead of requesting tiles that don't exist
attribution string none Templates only: attribution text or HTML
encoding "mvt" \| "mlt" "mvt" Tile format. A TileJSON that declares MapLibre Tiles works on its own

Vector tile servers work like PMTiles archives: sourceLayers, symbology, collapsed, the expandable row for two or more source layers, automatic colors, draw order, popups and the legend all behave as described under PMTiles.

For image tiles (hillshade, imagery), set "raster": true, with rasterOpacity and tileSize as for Raster PMTiles:

{
  "id": "esri-hillshade",
  "type": "tiles",
  "raster": true,
  "name": "World Hillshade",
  "url": "https://server.arcgisonline.com/ArcGIS/rest/services/Elevation/World_Hillshade/MapServer/tile/{z}/{y}/{x}",
  "tileSize": 256,
  "maxTileZoom": 16,
  "rasterOpacity": 0.5
}

Tile server layers can't be filtered or exported, since only the tiles in view are loaded. The demo's Tile Servers group has a TileJSON, a template, an MLT TileJSON and a raster hillshade.

flatgeobuf

FlatGeobuf (.fgb) is a streamable vector format. The kit converts it to GeoJSON in the browser, so popups, the legend and the rest work the same as for geojson.

Field Type Default Description
url string none Path to a .fgb file, local or remote
bbox "all" \| "dynamic" "all" How to load it
  • "all" reads the whole file into memory once. Fine for small and medium files; the size limits are the same as GeoJSON.
  • "dynamic" uses the file's spatial index to fetch only the features in view, and fetches again when the map moves. That's how to use a large remote .fgb (hundreds of MB or more) without downloading all of it.

Dynamic mode needs an uncompressed host with Range support

dynamic reads byte ranges, so the server must send the file uncompressed and support HTTP Range requests. Hosts that gzip responses, such as GitHub Pages, send data the browser can't decode, and dynamic mode fails. S3, other object stores and most CDNs work.

esri

ArcGIS REST services, through esri-gl. service picks the kind of service. esri-gl creates the source and the kit adds the styled layers. esri-gl is downloaded only when an app has an Esri layer, the first time one loads.

service Source What works
"feature" GeoJSON, queried for the current view Kit styling, popups and the legend
"dynamic" An image rendered by the server Visibility and opacity only; no legend or popups
"tiled" Cached raster tiles Visibility and opacity only

Feature service

{
  "id": "buoys",
  "type": "esri",
  "service": "feature",
  "name": "NDBC Buoys",
  "geometryType": "point",
  "url": "https://services5.arcgis.com/.../FeatureServer/0",
  "minZoom": 4,
  "symbology": { "kind": "simple", "color": "#a5d7f7", "radius": 5, "strokeColor": "#fff", "strokeWidth": 1 },
  "popup": { "displayType": "rich" }
}

For a feature service, symbology is optional. Set it to style the layer with the kit, or leave it out to use the service's own ArcGIS styling. It also takes where, an ArcGIS where clause (default "1=1").

Raster services

{
  "id": "usa-dynamic",
  "type": "esri",
  "service": "dynamic",
  "name": "USA States/Counties",
  "url": "https://sampleserver6.arcgisonline.com/.../USA/MapServer",
  "layers": [2, 3],
  "esriOpacity": 0.7
}

dynamic takes an optional layers list: the MapServer sublayer ids to draw. tiled takes only the common fields.

Raster services, and feature services drawn with the service's own styling, have no kit symbology to hold an opacity. Set their starting opacity with esriOpacity (0 to 1, default 1). A feature service with kit symbology uses the symbology's opacity and strokeOpacity instead.

geoparquet

GeoParquet files are read by DuckDB, which runs in the browser and converts them to GeoJSON.

Field Type Description
url string Path to a .parquet or .geoparquet file. A remote file needs CORS
limit number Stop after this many features. Leave it out to read the whole file
mvt boolean Draw the layer as vector tiles cut on the fly (see below). Default false
maxTileFeatures number With mvt: most features per tile; the smallest are dropped first. Default 5000
maxTileZoom number With mvt: highest zoom tiles are cut at. Default 14
  • Geometry: WKB, WKT and native geometry columns all work. The column comes from the file's GeoParquet metadata, or a common name like geometry or geom. A plain Parquet file with lon and lat columns works too, as points.
  • Projections: files in another coordinate system are reprojected to WGS84 automatically, using the CRS in the file's metadata. The demo's storm-reports-geoparquet-5070 layer is the storm reports file reprojected to EPSG:5070 (CONUS Albers), and it draws exactly on top of the original. To make one yourself with GeoPandas:

    import geopandas as gpd
    gdf = gpd.read_parquet("app/projects/demo/layers/storm_reports.parquet")
    gdf.to_crs(5070).to_parquet("app/projects/demo/layers/storm_reports_5070.parquet")
    

Large files

A GeoParquet layer reads the whole file. For large files, set a limit, trim the file, or draw it as tiles on the fly. For very large data that doesn't change, PMTiles is still the best choice.

Tiles on the fly (mvt)

Plain GeoParquet is the default choice. MapLibre already tiles it internally, simplifying shapes and dropping tiny polygons at low zoom, and for small and medium files it pans more smoothly. Set "mvt": true when a file is too big for that path, or to keep the data off the main thread (which also makes basemap switches lighter).

With mvt, the file loads once into DuckDB's memory (about three times its size), reprojected to Web Mercator, and MapLibre fetches vector tiles that DuckDB cuts as you pan. Each tile drops features smaller than about a pixel and keeps the largest maxTileFeatures, like tippecanoe's "drop smallest as needed": dense areas thin out at low zoom and show everything once a tile is small enough to fit them.

  • Tiled layers start at zoom 10 unless the layer sets minZoom.
  • maxTileZoom (14, the tippecanoe and Planetiler default, about 0.6 m precision) is the last zoom tiles are cut at; MapLibre keeps drawing past it. Raise it for very dense data that's still thinned at 14; lower it for coarse data.
  • Filters, Export (in WGS84) and Zoom to Extent still work. Popups, the legend and labels work as for any vector-tile layer.
  • Files over 100 MB log a console warning suggesting PMTiles.

GeoJSON layers get the same with "duckdb": { "mvt": true }. The demo has scott-buildings (plain) and scott-buildings-mvt (tiled) side by side to compare.

Optional: a covering bbox and spatial sorting

GeoParquet 1.1 files can carry a per-row bounding box column and be sorted spatially, which lets other tools, and partial reads of remote files, skip chunks that aren't needed. The kit's tiling doesn't need either. With GeoPandas 1.0 or later:

gdf = gdf.iloc[gdf.hilbert_distance().argsort()]
gdf.to_parquet("out.parquet", write_covering_bbox=True)

DuckDB

GeoParquet layers, and GeoJSON layers with duckdb, are read by DuckDB-WASM (DuckDB 1.5.6). It runs in a background worker, so reading doesn't block the page.

  • When it loads: at startup, but only if at least one such layer is configured, whether or not it's visible. An app without one never downloads it, neither the engine nor its JavaScript client (about 200 kB). The loading overlay shows each step and stays up until those layers' data is on the map, and a layer turned on later appears instantly. With map.onlyVisibleOnLoad, a hidden DuckDB layer loads when it's first turned on instead, with a spinner in the layer list while it reads; if every DuckDB layer starts hidden, DuckDB itself isn't downloaded until then.
  • Size: about 11.5 MB on the first visit (DuckDB plus its parquet, httpfs and spatial extensions). The browser caches it after that.
  • Where it comes from: jsDelivr for DuckDB itself, and extensions.duckdb.org for the extensions, pinned to the version the app installs. If DuckDB can't load (a blocked CDN, for example), those layers stay empty with a console warning, and the rest of the map keeps working.

To host the files yourself, for an offline or intranet deploy, set duckdb in settings.json:

"duckdb": {
  "bundleUrl": "https://intranet.example.com/duckdb/",
  "extensionsUrl": "https://intranet.example.com/duckdb-extensions"
}
Field Type Default Description
bundleUrl string jsDelivr Folder holding duckdb-eh.wasm, duckdb-browser-eh.worker.js, duckdb-mvp.wasm and duckdb-browser-mvp.worker.js, from the same @duckdb/duckdb-wasm version the app installs
extensionsUrl string extensions.duckdb.org Extension repository for parquet, httpfs and spatial, matching that DuckDB version

Content Security Policy

If your site sends a Content Security Policy, allow https://cdn.jsdelivr.net (scripts, worker and WASM), https://extensions.duckdb.org (extension WASM) and blob: for workers. Self-hosting changes the first two to your own URLs.

Labels

Any vector layer can have text labels through a label block.

Field Type Default Description
field string none Column to label by
expression MapLibre expression none A MapLibre expression for the text; wins over field. Use it to combine columns, e.g. ["concat", ["get", "name"], " (", ["get", "code"], ")"]
fontSize number 12 Text size in px
bold boolean false Noto Sans Bold instead of Noto Sans Regular
color string "#333333" Text color
haloColor string "#ffffff" Outline color, for legibility
haloWidth number 1 Outline width; 0 for none
minZoom number the layer's Zoom at which labels appear, so the geometry can show first
anchor "center" \| "top-left" \| "top-right" \| "bottom-left" \| "bottom-right" "center" Label position relative to the feature
allowOverlap boolean false Draw every label even where they collide. By default MapLibre drops colliding labels

The basemap must serve the font

MapLibre draws labels on the map canvas with fonts from the basemap style's glyphs URL. Labels use Noto Sans (Regular, or Bold with "bold": true), which every OpenFreeMap style (the default basemaps) serves. A style with no glyphs URL can't draw labels at all.

To set label defaults for every layer at once, use ui.layerDefaults.label.

Filtering

Add filters to a layer to let users filter it from the Layer List. Each entry names a column and how to filter it:

On a layer in layers.json:

"filters": [
  { "column": "state", "kind": "categorical", "label": "State" },
  { "column": "passengers", "kind": "numeric", "label": "Annual Passengers" }
]
Field Type Default Description
column string required Feature property to filter on
kind "categorical" \| "numeric" required categorical: a checklist of the column's values, read from the data. numeric: one condition the user types in (>, between, …)
label string column Name shown in the filter panel

Which layer types can be filtered:

Layer type Filterable How
geojson yes In the browser
geoparquet yes In the browser, once the file has loaded
esri feature yes On the server, combined with the layer's where
pmtiles, tiles no Only tiles in view are loaded, so the full list of values isn't available
flatgeobuf no Not supported
esri dynamic / tiled no Rendered by the server; there are no features to filter

Adding filters to an unsupported type, or listing the same column twice, stops the app at boot with an error pointing at settings.layers["<id>"].filters.

Esri value lists are capped

An Esri feature layer gets its categorical values from one query, so the list stops at the service's maxRecordCount (often 1,000 to 2,000). Pick categorical columns with far fewer distinct values than that. Numeric filters aren't affected.

Custom legend

The legend normally reads each layer's symbology. Set legend on a layer (or on a PMTiles or tiles source layer) to choose what it shows instead. It takes the same three shapes as a raw symbology's legend, and wins over it:

An Esri raster service, which has no kit symbology to read:

{
  "id": "usa",
  "type": "esri",
  "name": "USA",
  "service": "dynamic",
  "url": "https://sampleserver6.arcgisonline.com/arcgis/rest/services/USA/MapServer",
  "layers": [2, 3],
  "legend": {
    "kind": "categories",
    "entries": [
      { "label": "States", "color": "#94a3b8" },
      { "label": "Counties", "color": "#cbd5e1" }
    ]
  }
}
  • categories: labeled swatches. Raster layers draw them as squares; other layers use their geometryType (dot, line or square).
  • gradient: a continuous bar, with the first and last labels at its ends.
  • hidden: leaves the layer out, like "hideFromLegend": true.

Rasters and Esri layers styled by the service only appear in the legend when they set legend.

Hiding a layer from the legend

Set "hideFromLegend": true on any layer to keep it on the map and in the layer list but out of the legend. It works with every layer and symbology kind, as does "legend": { "kind": "hidden" } on the layer.