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.

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: true gates 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 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.