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);
A query returns at most 1,000 features. That's an output cap, not a limit on how much is searched — the whole source is evaluated, you just get up to 1,000 back. Every result carries a 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 often
Tile caching is version-stamped: a put 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 in env.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's properties._meters is how far it is. Returns the nearest limit (max 1,000) with a truncated flag.
  • within(source, polygon, { limit, where, cursor }) · intersects(source, geometry, { limit, where, cursor }) — return { features, nextCursor, truncated }; pass nextCursor back as cursor to 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 }; pass nextCursor back to page.
  • delete(source, id | [ids]){ deleted } — remove individual features by their id.
  • 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 call importFromStorage (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 .pmtiles file to storage, and point MapLibre's pmtiles:// protocol at /files/<key> with a minzoom. 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.