Spatial queries
env.GEO stores geometry and answers geographic questions, what's near a point, what's inside an area, what intersects a shape, backed by managed PostGIS. It also renders those datasets on a map, and it scales to millions of features (e.g. every property plot in a country), because tiles are generated per-request straight from the database.
env.GEO is a paid-plan feature (it runs on a managed PostGIS database). Reference it in any api/*.js file and it's wired up on deploy. For just showing geometry on a map, no queries, use env.MAP , that's free on every plan.Store features
Group features under a named source. put replaces the whole source by default; pass { replace: false } to add or update instead.
await env.GEO.put("stores", {
type: "FeatureCollection",
features: [
{ type: "Feature", properties: { name: "HQ" },
geometry: { type: "Point", coordinates: [10.75, 59.91] } },
],
});Query
Every query returns a GeoJSON FeatureCollection, hand it straight to env.MAP.putSource(...) or a MapLibre source to draw the results on a map.
// Within a radius, nearest first (each result carries properties._meters)
const near = await env.GEO.nearby("stores", {
lng: 10.75, lat: 59.91, radiusMeters: 5000, limit: 20,
});
// Fully inside a polygon
const inside = await env.GEO.within("stores", {
type: "Polygon",
coordinates: [[[10.6,59.8],[10.9,59.8],[10.9,60.0],[10.6,60.0],[10.6,59.8]]],
});
// Anything that intersects a geometry (point, line, polygon…)
const hit = await env.GEO.intersects("zones", someGeometry);truncated flag that's true when the cap was hit and there's more. within, intersects and list are cursor-paginated: when nextCursor is set, pass it back as { cursor } and loop until it's null to read everything. (nearby returns the nearest 1,000 in order and has no cursor, shrink the radius, or use list, to go beyond that.)// Read EVERY plot inside a large area, 1,000 at a time
let cursor, all = [];
do {
const fc = await env.GEO.within("plots", bigPolygon, { limit: 1000, cursor });
all.push(...fc.features);
cursor = fc.nextCursor; // null on the last page
} while (cursor);Render millions of rows on a map
Anything you put is also renderable directly, no separate env.MAP upload. Point MapLibre at env.MAP.styleUrl() and your GEO sources show up as layers.
This is the path for large datasets, tens of thousands up to millions of features (think every cadastral plot in a region). Each tile is generated on demand with a bounded query that reads only the features inside that tile (a spatial-index lookup), so the map stays fast no matter how big the source is, it never loads the whole dataset into memory. That's the difference from env.MAP.putSource, which is best for smaller sources.
// Mark rarely-changing data static → tiles cache aggressively.
// For data that changes often, omit it (or set a short cacheTtl in seconds).
await env.GEO.put("plots", bigFeatureCollection, { static: true }); // e.g. property plots
await env.GEO.put("orders", liveFeatureCollection, { cacheTtl: 30 }); // changes oftenput bumps the source's version so a static layer's tiles cache ~forever yet update the moment the data changes. One engine serves both static and live data.Private, per-viewer maps: rendered tiles are PUBLIC by default. For per-user or per-tenant data, mark the source { private: true } and render it with a signed, ACL-filtered style URL, the server filters the features per viewer before building the tile. Spatial queries are untouched. See Access-controlled maps.
await env.GEO.put("plots", featureCollection, { private: true, static: true });Loading a very large dataset: one put takes up to 25,000 features and completes in ~seconds (the whole chunk inserts in a single database round-trip). For more, load in chunks , the first with the default replace (clears the source), the rest with { replace: false } to append:
const CHUNK = 20000;
for (let i = 0; i < features.length; i += CHUNK) {
await env.GEO.put("plots",
{ type: "FeatureCollection", features: features.slice(i, i + CHUNK) },
{ replace: i === 0, static: true });
}API
put(source, geojson, { replace = true, static, cacheTtl, private }), store features (also makes the source map-renderable). Up to 25,000 features per call.private: truegates the rendered tiles behind a signed URL, see Access-controlled maps.importFromStorage(source, storageKey, { static, cacheTtl }), bulk-load a large GeoJSON you already wrote to env.STORAGE. Pass the same key you used inenv.STORAGE.put(key, data)(no leading slash). Parsed and inserted server-side in the background (no per-call chunking, not bound by request time); replaces the source. Returns{ importing: true }, the source is queryable a short time later. It reads the current deploy's storage, so upload and import from the same deploy (preview deploys have their own storage).nearby(source, { lng, lat, radiusMeters, limit, where }), distance-sorted; each result'sproperties._metersis how far it is. Returns the nearestlimit(max 1,000) with atruncatedflag.within(source, polygon, { limit, where, cursor })·intersects(source, geometry, { limit, where, cursor }), return{ features, nextCursor, truncated }; passnextCursorback ascursorto page past the 1,000-per-call cap.list(source, { limit, cursor, where }), non-spatial paged read (for record lists/CRUD), returns{ features, nextCursor, truncated }; passnextCursorback to page.delete(source, id | [ids])→{ deleted }, remove individual features by theirid.clear(source)→{ deleted }(remove every feature in a source) ·count(source)→{ count }.
Metadata, upserts & filtering
Each feature carries a properties object, your metadata. It can hold nested objects and arrays (no need to flatten or stringify), and it comes back on every query result.
Give a feature a stable id to make it individually addressable. A put with { replace: false } then upserts on that id (updates the feature in place instead of adding a duplicate); delete(source, id) removes it. Without anid, each write appends a new feature.
Any read (nearby, within, intersects, list) accepts a where object that matches only features whose properties contain those key/values, so you can combine a spatial query with an attribute filter, or page a filtered list without a spatial query.
// Upsert one feature (stable id) with metadata, then filter by it.
await env.GEO.put("plots",
{ type: "Feature", id: "gnr-42",
properties: { team_id: "t1", status: "for_sale", tags: ["corner"] },
geometry: { type: "Point", coordinates: [10.75, 59.91] } },
{ replace: false });
// Only this team's for-sale plots within 2 km:
const hits = await env.GEO.nearby("plots", {
lng: 10.75, lat: 59.91, radiusMeters: 2000,
where: { team_id: "t1", status: "for_sale" },
});
// Page a record list (no spatial query):
let cursor;
do {
const { features, nextCursor } = await env.GEO.list("plots", { limit: 100, cursor, where: { team_id: "t1" } });
// …render features…
cursor = nextCursor;
} while (cursor);
await env.GEO.delete("plots", "gnr-42");Loading a lot of data, and the limits
Pick the path by scale and what you need:
- Up to ~25k features, one
put. - Tens of thousands to a few hundred thousand, queryable , either loop
put(..., { replace: false })in 25k chunks, or upload the GeoJSON once and callimportFromStorage(simplest, the platform ingests it in the background). - Millions of features, display-only (e.g. every cadastral parcel in a country), env.GEO is the wrong tool at that scale. Build PMTiles locally (tippecanoe), upload the
.pmtilesfile to storage, and point MapLibre'spmtiles://protocol at/files/<key>with aminzoom. Cloudrizz serves/files/with HTTP Range support, which is what PMTiles needs to read tiles on demand, so a multi-GB tileset streams without loading it all. To rebuild the tileset on a schedule from an external job (no chat session), publish it with an unattended upload route.
Hard limits: 25,000 features per put; a single importFromStorage file is capped at 40 MB; a read returns at most 1,000 features per call (page with nextCursor, see Query above). Each explicit env.GEO call counts as one geo query against your plan's monthly allowance (tile rendering does not), see Limits & quotas.
Notes
- Coordinates are
[longitude, latitude](GeoJSON order), WGS84. - Distances are in meters, measured on the spheroid.
- Each app's geometry is isolated from every other app's.
- Each call counts as one geo query against your plan's monthly allowance, see Limits & quotas.