BG
FR
All articles
#SIG#PostGIS#OpenStreetMap

Roads, buildings, points of interest: a country in OpenStreetMap is millions of features. Here's how to serve them as vector tiles straight from PostGIS, without bringing the server down.

Boris Gautier5 November 2026 2 min read

On GeOsm, each country has its own map, built from OpenStreetMap data. For Cameroon, that means several million buildings, road segments and points of interest. Sending this to the browser as GeoJSON is out of the question: a few square kilometres of city already weigh several megabytes. The standard answer is the vector tile.

A tile is a query

The world is cut into tiles indexed by a zoom level z and coordinates x and y. A vector tile (MVT format) contains the geometry and attributes of the features visible in that square, compactly encoded and already projected to screen coordinates. The browser, with MapLibre for instance, applies the style. Since PostGIS 3, the database can build these tiles on its own: one query per tile.

tiles/roads.sql
-- $1 = z, $2 = x, $3 = y. Geometries are stored in EPSG:3857 (web mercator).
WITH bounds AS (
  SELECT ST_TileEnvelope($1, $2, $3) AS geom
),
features AS (
  SELECT
    ST_AsMVTGeom(r.geom, bounds.geom, 4096, 64, true) AS geom,
    r.osm_id, r.name, r.highway
  FROM roads r, bounds
  WHERE r.geom && bounds.geom                      -- uses the GiST index
    AND ($1 >= 13 OR r.highway IN ('motorway', 'trunk', 'primary', 'secondary'))
)
SELECT ST_AsMVT(features, 'roads', 4096, 'geom') FROM features;

ST_TileEnvelope computes the tile's square, ST_AsMVTGeom clips and simplifies each geometry to the tile's resolution (4,096 units) with a small 64-unit buffer to avoid seams between tiles, and ST_AsMVT packs everything into binary. The highway condition matters most: at small scales, only major roads are sent.

The four rules that make the difference

  • Store geometries directly in 3857 (or keep a precomputed column): transforming millions of geometries on every tile is expensive.
  • A GiST index on the geometry and an && filter on the tile's extent: that's what turns a full table scan into a few milliseconds.
  • A level of detail per zoom: no buildings below zoom 14, no footpaths below 13, generalised geometries for low zooms.
  • Hand-picked attributes: send only what the style and pop-ups use, not every one of the 80 OSM tags on each feature.

Caching does the rest

OSM data changes, but not by the second. A generated tile can be kept: in memory or on disk on the server, then in browser and CDN caches thanks to HTTP headers. On GeOsm, data is refreshed every night: a cache lifetime of a few hours is enough, and most tiles are only computed once a day. The busiest zoom levels can even be generated ahead of time right after the import.

tiles/route.ts
app.get("/tiles/:layer/:z/:x/:y.pbf", async (req, reply) => {
  const { layer, z, x, y } = TileParams.parse(req.params); // whitelist of layers, integer z/x/y
  const key = `${layer}/${z}/${x}/${y}`;

  const tile = (await cache.get(key)) ?? (await cache.set(key, await renderTile(layer, z, x, y)));

  return reply
    .header("Content-Type", "application/vnd.mapbox-vector-tile")
    .header("Cache-Control", "public, max-age=3600, stale-while-revalidate=86400")
    .send(tile); // an empty tile is a valid answer: send it, and cache it too
});

What you get

A smooth map even on 3G, because a tile weighs a few dozen kilobytes. A style you can change without touching the data, light or dark. Clicks that return the features' real attributes. And a single source of truth: the PostGIS database, the same one used for analysis and extracts. For a platform built for Africa, where mobile connections are the norm, this isn't an optimisation detail: it's what makes the map usable at all.

Read next