GeoLens is a self-hosted spatial data hub: catalog, search, maps, analysis, and open APIs over data that stays on your own infrastructure. This repo holds copy-paste integrations for the tools your stack already uses. Every browser, Python, and DuckDB example reads the public demo anonymously, so you clone, open, and see it run; one constant at the top points it at your own instance. The exception is cli/, which publishes to a catalog and so runs against your own instance with a credential.
Search the catalog by meaning, connect from desktop GIS, or embed a map, with the same self-hosted catalog and open APIs underneath.
- Live gallery: every live browser example running, arranged by what you are trying to do. Open one before you clone anything.
- Try GeoLens: the public demo these examples read. Its catalog and its saved maps open without an account.
- Main repository: GeoLens itself, with the install script, the docs, and the issue tracker.
If GeoLens is useful to you, star it on GitHub. That is how most people find it. If these examples save you an afternoon, star or watch this repo too.
GeoLens serves OGC API Features and Records, STAC 1.0, XYZ vector tiles (MVT), and raster tiles (API reference). These examples show those surfaces from the consumer's side.
The table below is arranged by tool; the numbered steps here trace the platform from an empty instance to a map someone else can use.
- Install.
curl -fsSL https://getgeolens.com/install.sh | shstarts the stack with Docker Compose; openhttp://localhost:8080about a minute later (install guide). The public demo covers the read steps below without an install. - Publish two sources. Declare them in a manifest and run
geolens apply.cli/holds ageolens.yamlwith two Natural Earth layers and the GitHub Actions workflow that applies it (CLI guide). - Find them by meaning.
search/catalog.htmlasks the catalog for a phrase rather than a title. On your own instance semantic search is off until an admin adds an embedding provider, turns it on under Admin > Settings > AI, and runs the embedding backfill (search guide). - Build a map. Add layers from the catalog, style each one, set the viewport, save (map builder guide). The demo's showcase maps came in through the same maps API, by script rather than by hand.
- Analyze on the server. The builder's Analysis panel runs buffer, intersect, dissolve and five more operations in PostGIS and writes the result back to the catalog as a new dataset; all but dissolve preview on the map first (analysis guide). The demo answers anonymous analysis calls with 401, so there is no live example here;
python/analyze.pydoes a comparable spatial join client-side. - Publish or embed it. A share link gives the map a stable
/m/<token>URL, and?embed=trueputs it in an iframe on a page that is not GeoLens:embed/iframe.html. - Read it back from anywhere. The same catalog answers QGIS over OGC API Features (
qgis/), the Python SDK (python/sdk-catalog.py), the TypeScript SDK (typescript/catalog-map.html), leafmap and GeoPandas in a notebook (leafmap/quickstart.ipynb), and plain SQL in DuckDB (duckdb/query.py).
| Example | Tool | Demonstrates | Run it |
|---|---|---|---|
maplibre/vector-tiles.html |
MapLibre GL JS 6.x | Vector tiles (MVT) cut per request from PostGIS | Live |
maplibre/features.html |
MapLibre GL JS 6.x | GeoJSON features, click identify with no round-trip | Live |
maplibre/features-viewport.html |
MapLibre GL JS 6.x | Features by viewport: bbox, rel="next" paging, cancellation, and the cap where vector tiles take over |
Live |
maplibre/imagery.html |
MapLibre GL JS 6.x | Raster tiles through a WebGL texture (needs CORS) | Live |
maplibre/pmtiles.html |
MapLibre GL JS 6.x + pmtiles 4.5 | A PMTiles export as one committed static file: range reads where the host answers 206, no tile server either way | Live |
maplibre/pmtiles-live.html |
MapLibre GL JS 6.x + pmtiles 4.5 | The same protocol pointed straight at a live export URL, no file committed: cross-origin range reads, served with CORS from GeoLens 1.16.1 on | Live |
arcgis-js/features.html |
ArcGIS Maps SDK for JavaScript 5.1 | OGCFeatureLayer against the OGC API landing page |
Live |
arcgis-js/imagery.html |
ArcGIS Maps SDK for JavaScript 5.1 | WebTileLayer with Esri's {level}/{col}/{row} names |
Live |
openlayers/features.html |
OpenLayers 10 | OGC API Features, CRS84 reprojected on read | Live |
openlayers/imagery.html |
OpenLayers 10 | XYZ raster, and what crossOrigin costs you |
Live |
leaflet/features.html |
Leaflet 1.9 | GeoJSON features straight into L.geoJSON |
Live |
leaflet/imagery.html |
Leaflet 1.9 | Raster tiles as plain <img>, so no CORS needed |
Live |
typescript/catalog-map.html |
@geolens/sdk 1.20.0 + MapLibre |
Catalog search, schema and freshness, then the tile link the collection advertises (TypeScript SDK guide) | Live |
search/catalog.html |
MapLibre GL JS 6.x + fetch |
Semantic catalog search, narrowed to the map view, then drawn | Live |
embed/iframe.html |
No library | A saved GeoLens map in an iframe, styling and legend intact | Live |
stac/browse.html |
MapLibre GL JS 6.x | STAC item search over the map view, then the tile asset each item advertises | Live |
python/analyze.py |
Python (single-file uv run script) |
Features API → GeoPandas spatial join, metric-CRS analysis, styled plot | uv run python/analyze.py |
python/sdk-catalog.py |
geolens 1.20.0 (single-file uv run script) |
SDK catalog search, schema semantics, a server-side CQL2 filter count, export into GeoPandas (Python SDK guide) | uv run python/sdk-catalog.py |
leafmap/quickstart.ipynb |
leafmap + GeoPandas (Jupyter notebook) | Catalog search, OGC API Features into GeoPandas, CQL2 filters on the catalog and on a dataset's rows, raster tiles rendered by TiTiler | uv run --with jupyterlab --with ipykernel --with pip jupyter lab leafmap/quickstart.ipynb |
leafmap/samgeo.ipynb |
leafmap + samgeo (Jupyter notebook) | STAC item search by footprint, the two assets a by-reference import preserves, raster tiles, optional Segment Anything segmentation | uv run --with jupyterlab --with ipykernel --with pip jupyter lab leafmap/samgeo.ipynb |
mcp/ |
Any MCP client via the GeoLens MCP server | Catalog search, schema, spatial queries, and tool chaining from an AI assistant (MCP server guide) | claude mcp add geolens -e GEOLENS_INSTANCE=https://demo.getgeolens.com -- uvx geolens-mcp@1.20.0 |
qgis/ |
QGIS 4.2 | OGC API Features + Records with CQL2, XYZ raster, tile-token auth | https://demo.getgeolens.com/api/ |
duckdb/query.py |
DuckDB 1.5 + spatial (single-file uv run script) |
One SQL join across the GeoParquet export and the Features API, with column pruning over HTTP ranges | uv run duckdb/query.py |
cli/ |
geolens-cli 1.20.0 |
Catalog-as-code: offline validate, then apply --dry-run and apply against your instance |
uvx --from geolens-cli==1.20.0 geolens validate cli/geolens.yaml |
Every browser row above with a Live link is checked against the live demo by ci/verify-examples.mjs, which asserts the documented data loaded and the map painted; a 200 response alone does not pass. Where an example draws two layers of its own in fixed colours, it also asserts each one painted, by colour. The three uv run scripts run green with one command each, and each asserts its own answers rather than just finishing. leafmap/quickstart.ipynb and leafmap/samgeo.ipynb are checked the same way, one level up: leafmap/verify.py and leafmap/verify_samgeo.py each execute their own notebook headlessly and fail on the first cell that raises.
MapLibre examples also work with Mapbox GL JS with minimal changes (both consume the same MVT and raster sources).
Each browser example is one static HTML file with no build step: the library loads from a pinned CDN. Serve the folder and open the page from there. Opened from disk, Chrome and Edge can't start MapLibre 6's worker, so vector and GeoJSON layers never draw and the status line says "Worker failed to load". That comes from the file:// URL, not from CORS. Firefox and Safari draw most pages from disk, but maplibre/pmtiles.html reads a file next to itself and needs the server:
python3 -m http.server 8000
# then visit http://localhost:8000/maplibre/vector-tiles.htmlEach example declares its target at the top:
const GEOLENS = "https://demo.getgeolens.com";Change it to your instance URL.
These examples need GeoLens v1.13.0 or newer. Raster tiles only started sending Access-Control-Allow-Origin in that release (geolens#1464), and without it the MapLibre and ArcGIS imagery examples draw an empty map while the server returns perfectly valid PNGs. The features and vector-tile examples reach further back: the anonymous CORS wildcard they depend on has been there since v1.4.7.
Dataset IDs and table names in these examples belong to the demo catalog. Against your own instance, list what's available at /api/collections and substitute your collection IDs. The demo gets reset from time to time, and a reset can change its dataset UUIDs, so treat the IDs hardcoded here as demo-specific rather than as part of any API.
CI replays every live browser example against the demo on every pull request, on each push to main, and every morning on a schedule, so an ID or share link that stops resolving turns the build red instead of quietly leaving you with a blank map. Those fixtures are named in ci/fixtures.json and probed before the browser sweep runs, so a reset shows up as a red preflight naming what moved rather than as every example failing at once.
Anonymous cross-origin reads work with no setup: GeoLens answers the standards paths (/api/collections, /api/stac, conformance), and from GeoLens 1.14.1 the native catalog search at /api/search/datasets/, with Access-Control-Allow-Origin: * as long as the request carries no credential. Send a credential and that wildcard is gone, so your page's origin has to be listed in the instance's CORS_ALLOWED_ORIGINS. A literal * there is rejected, since credentialed CORS requires explicit origins.
The demo is public, so none of these examples send a credential. On your own instance, pick the method your client can actually use:
| Client | Use |
|---|---|
fetch, an SDK, Python, GDAL, ArcGIS request interceptors |
X-Api-Key: <key> header, or Authorization: Bearer <jwt> |
| A static XYZ/MVT URL template that cannot set headers | a signed tile token, scoped to one dataset and short-lived |
| Public data, including everything in the demo | nothing |
The ?api_key= query parameter carries the same key as the header, but from GeoLens 1.18.1 it only counts on GET, HEAD and OPTIONS requests. On anything else, a POST read such as /api/stac/search included, the key is treated as absent (_supplied_api_key in backend/app/modules/auth/dependencies.py, geolens#1845). The order GeoLens checks credentials in is documented in the authentication guide and implemented by _resolve_api_key and get_optional_user in the same file.
Prefer the header. A key in a URL ends up in browser history, server access logs, every proxy log along the way, analytics, screenshots, and anything anyone copy-pastes, which is why GeoLens deprecated the query lane in geolens#821 and kept it only for clients that genuinely cannot set a header. Desktop GIS consuming an XYZ template is the case it exists for.
Do not put a long-lived API key in a static HTML file, and do not commit one. Anyone who reads the page source has your key with all of your access until someone notices and revokes it. For a client that only reads, such as a dashboard, a CI check or a notebook, mint the key read_only: it authenticates reads and nothing else, so a leak cannot turn into a write (API keys).
GET /api/tiles/token/<dataset_id>/ mints a token for a single dataset (the tile endpoints reference covers what a tile token is and why it is not an API key). A vector dataset returns sig, exp, and scope to append to the tile template; a raster dataset returns the whole tile_url with those already in the query string.
curl https://demo.getgeolens.com/api/tiles/token/d8fd56a9-d12f-4dbb-af8b-81a7289fc600/
# {"kind":"raster",
# "tile_url":"/raster-tiles/d8fd.../tiles/{z}/{x}/{y}.png?sig=7031...&exp=1790127900&scope=d8fd...%3Ap0&v=1&pv=0",
# "expires_in":160, ...}exp is always a 15-minute boundary, usually the next one. When that boundary is under a minute away the mint skips to the following one instead, so a fresh token carries anywhere from 60 seconds to just under 16 minutes. Read expires_in off the response rather than assuming a fixed TTL. POST /api/tiles/tokens/ mints up to 50 in one call for a multi-layer map.
Two properties decide whether this fits your page.
Minting is itself authorized. A public, published dataset hands a token to anyone, which is why the curl above works signed out. A private one answers an anonymous mint with 401, so a page holding no credential cannot mint its own token and something server-side has to hold the key and pass tokens down. A scoped token does not remove the need for a credential. It keeps the credential out of the browser.
Tokens expire and clients do not renew them on their own. MapLibre keeps requesting whatever template you handed it, so a page that stays open has to re-mint and reset the source URL before exp passes. From GeoLens 1.19.0 a token also stops working within a minute of its dataset being unpublished or made private, whatever time it had left (tile endpoints).
X-Embed-Token is a different mechanism and not a substitute here. Embed tokens are minted per map by an authenticated owner, and the tile routes read them from the header only, so one cannot ride along in a URL template.
The three features.html examples fetch every feature once with ?limit=2000 and hold the whole result in browser memory. That works for the demo's subway layers (496 stations, 29 lines). It is the wrong shape for a parcel, road, or building layer, where the same code silently renders one truncated page.
Use OGC API Features when the result is small or bounded, when you need attributes on the client, when you load by viewport or filter instead of all at once, or when the page interacts with individual features.
Use vector tiles when the dataset is large, when users pan and zoom across all of it, when not every feature needs to reach the browser, and when rendering performance matters more than holding the full attribute table. maplibre/vector-tiles.html shows that path: the server cuts MVT per tile and the client holds only what is on screen.
Every items response says which case you are in:
curl "https://demo.getgeolens.com/api/collections/4e7cba4c-4caa-4609-b5c4-3c6cd252697c/items?limit=2" \
| jq '{numberMatched, numberReturned}'
# { "numberMatched": 496, "numberReturned": 2 }numberMatched is what the query found; numberReturned is what this page contains. When they differ you are holding a partial result, which is the signal that a one-shot fetch has truncated your data.
It is not the signal that another page exists. Walk the stations collection at limit=400 and the last page returns 96 of 496 matched, counts differing, with no next link on it. The rel="next" link is the authority: follow it until it stops appearing, and read the counts as a diagnostic rather than a loop condition. Paging is keyset-based (after_gid=), so rows do not shift under a reader mid-scan. python/analyze.py does exactly that in a few lines. maplibre/features-viewport.html is the same guidance in a browser: it requests bbox=<view> on every settled view, follows next, cancels the walk a pan made stale, and stops at a per-view cap that says on screen when to switch to vector tiles. From GeoLens 1.18.0 the counts can also be approximate: past 20,000 matches on a filtered request (bbox, a property filter or CQL2), numberMatched is the database's estimate and the response carries X-GeoLens-Number-Matched: estimated (paging).
MIT. The examples are intentionally small; copy them into your project freely.


