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.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 })— store features (also makes the source map-renderable). Up to 25,000 features per call.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.