Maps & vector tiles

env.MAP puts an interactive map with your own data on it. You store GeoJSON sources, and Cloudrizz serves them as vector tiles and hands you a ready-made MapLibre style, no tile server to run and no API keys to manage. Available on every plan.

Add your data

A source is a named GeoJSON FeatureCollection (or a single Feature / geometry). Store one from any api/*.js route; calling putSource again with the same name replaces it. Up to 50 MB per source.

// api/seed.js, load a source (e.g. from an admin route, run once)
export default {
  async fetch(request, env) {
    await env.MAP.putSource("stores", {
      type: "FeatureCollection",
      features: [
        { type: "Feature", properties: { name: "HQ" },
          geometry: { type: "Point", coordinates: [10.75, 59.91] } },
      ],
    });
    return new Response("seeded");
  },
};

Show the map

Point MapLibre at your app's style URL, it already includes a basemap plus a layer for every source you've added. Get the URL from env.MAP.styleUrl() (no network call, it's just a string):

// api/mapstyle.js, hand the browser this app's style URL
export default { async fetch(request, env) { return new Response(env.MAP.styleUrl()); } };
<!-- index.html -->
<link href="https://unpkg.com/maplibre-gl/dist/maplibre-gl.css" rel="stylesheet" />
<script src="https://unpkg.com/maplibre-gl/dist/maplibre-gl.js"></script>
<div id="map" style="height:100vh"></div>
<script>
  fetch("/api/mapstyle").then((r) => r.text()).then((style) => {
    new maplibregl.Map({ container: "map", style, center: [10.75, 59.91], zoom: 10 });
  });
</script>

By default points render as circles, lines as strokes, and polygons as a translucent fill, so any geometry shows up without extra work.

API

  • putSource(name, geojson), create or replace a source (tiles regenerate on the next request).
  • deleteSource(name) · listSources() → { sources: [...] }.
  • styleUrl(), the MapLibre style URL (basemap + all your sources).
  • tileUrl(name), the raw vector-tile URL template for one source, for building your own MapLibre style. Its source-layer is "data".
  • putSource(name, geojson, { private: true }), mark a source access-controlled (see below). Omit private to leave the current setting unchanged.
  • signedStyleUrl(opts) · signedTileUrl(name, opts) → Promise<string>, signed URLs for private sources.
By default, tiles are served from a shared, edge-cached, PUBLIC endpoint, so a map with thousands of features stays fast and cheap. The tile URL is guessable (app id + source name + version), and any property-based hiding you do in the browser is cosmetic: whoever fetches the tile URL gets every feature. So do NOT put per-user or per-tenant data in a plain source. For that, mark the source private and serve it with a signed URL, see the next section.

putSource is for smaller datasets (it builds the tile index in memory). For large datasets, tens of thousands up to millions of features, store them with env.GEO instead; those render on the map the same way (via styleUrl()) but tile straight from PostGIS, so they scale far higher.

Access-controlled (private) maps

Mark a source private and its tiles are served ONLY with a short-lived signed URL that your app mints server-side, after your own access check. The URL carries an ACL filter that the platform applies to the features before building the tile, so a viewer only ever receives their own rows, enforced on the server, not in the browser. Spatial queries (env.GEO) and normal rendering are unchanged; this only adds a gate in front of the tiles.

// Mark the source private when you write it (opt-in):
await env.GEO.put("plots", featureCollection, { private: true });
// (or env.MAP.putSource("plots", geojson, { private: true }) for a GeoJSON source)

Then, in a logged-in route where you know the viewer, hand the browser a signed style URL. Your code decides the filter, the platform enforces it:

// api/mapstyle.js, per-viewer signed style
export default {
  async fetch(request, env) {
    const user = await env.AUTH.requireUser(request);
    if (!user) return env.AUTH.redirectToLogin();
    const style = await env.MAP.signedStyleUrl({
      ttl: 900,                               // token lifetime (seconds)
      filter: { mandant_id: user.tenantId },  // tenant isolation (props containment)
    });
    return new Response(style);
  },
};

Two ACL shapes, which can be combined (both must match):

  • filter, a property-containment match, e.g. { mandant_id: "t1" }. Great for tenant isolation. A feature is shown only if its properties contain every key/value.
  • principals, { key, values } for per-row sharing: a feature is shown if its props[key] (an array you store on the feature) overlaps the viewer's values. Use it to share a specific row with specific users.
// Per-user sharing: each feature carries props.vis = ["owner:u5","tenant:t1","shared:u7"]
const style = await env.MAP.signedStyleUrl({
  ttl: 900,
  principals: { key: "vis", values: visiblePrincipalsFor(user) }, // e.g. ["owner:u5","tenant:t1"]
});

Other options: sources (which private sources the token unlocks, default all), include (narrow which sources the style renders), and signedTileUrl(name, opts) if you build your own MapLibre style instead of using the signed style URL.

Tokens are short-lived and signed with your app's own secret; nobody can widen the filter or forge one. Private tiles are still edge-cached, but partitioned per ACL scope, so one tenant's cached tiles are shared across that tenant's users and never served to another. Public sources are unaffected.

Querying geometry

env.MAP stores and displays geometry. For spatial queries, "what's within 5 km of here", "which zone contains this point", use env.GEO, the PostGIS-backed query tier. Its results come back as GeoJSON, so you can feed them straight into putSource to draw them.