DIY Vector Tile Server with Postgres, FastAPI and Async SQLAlchemy

Lawson Taylor
15 min readAug 4, 2024

--

I am once again asking you to just use Postgres

Introduction

These days data is everywhere, probably too much of it. But when it comes to geospatial data, it can be a bit of a pain to work with and sometimes can be slow and painful to visualize.

Humans are simple creatures, and for something to be useful to us we need to visualize it.

To view geospatial, data vector tiles offer a way to serve it in a compact and fast format so that it can be injected into a map.

In this article, we’ll build a simple vector tile server using FastAPI, PostGIS, and Async SQLAlchemy.

So instead of going for overly complex systems and services, just use Postgres (and FastAPI).

All the code for this project is ready and waiting for you in this GitHub repository. Please feel free to copy and paste as much as your heart desires.

Vector Tiles: So what are they really?

Taking a look at the Mapbox documentation:

A vector tile is a lightweight data format for storing geospatial vector data, such as points, lines, and polygons.

That’s a little obscure, so let’s try to distil it down a bit.

credit: https://www.researchgate.net/publication/258725283_A_Cache_Replacement_Policy_Based_on_Neural_Networks_Applied_to_Web_Map_Tile_Caching

In general, a digital map can display data at any scale for any area. The downside is that this is rarely efficient.

For example, if you’re looking at a map of the world, you don’t need to see the details of every road, street corner or lamppost. But as you zoom in these details need to become more prominent.

Map tiling helps improve speed and efficiency by constraining the map to specific zoom levels, each doubling the detail of the last as you go down through the zoom levels.

zoom level 0 = 1x1 = 1 tile
zoom level 1 = 2x2 = 4 tiles
zoom level 2 = 4x4 = 16 tiles
zoom level 3 = 8x8 = 64 tiles
...

Additionally, instead of displaying data for any location, a tiled map shows data within a fixed grid at each zoom level, combining these tiles to cover the desired area.

credit: https://developer.myptv.com/en/documentation/vector-maps-osm-api/concepts/tiles-vector-map-osm-api

At each zoom level z, the tiles are indexes by coordinates (z, x, y), so that to fetch a specific tile we can send a request to http://host/{z}/{x}/{y}.{format} where the format determines the datatype being returned. In out case it will be mvt or vnd.mapbox-vector-tile for Mapbox Vector Tiles.

The Goal

Our goal is to build a simple vector tile server that can dynamically serve vector tiles for a given area and zoom level. We’ll use FastAPI to serve the tiles, PostGIS to store the geospatial data, and Async SQLAlchemy to query the data.

Our data source will be all of the Airbnb listings in the Balearic Islands (Ibiza, Mallorca, Menorca, and Formentera).

Finally, we’ll use a simple HTML page with Mapbox GL JS to display the tiles that are fetched from our FastAPI application

The final result will look something like this:

MapboxGL JS HTML template for FastAPI tile server

To help setup up the project we’ll use a variant of the ultimate FastAPI Setup, which you can check out here.

Docker Compose and the Services

There are two services we need to run our vector tile server:

  • Postgres with the PostGIS extension to store the geospatial data.
  • FastAPI to query Postgres and serve the vector tiles.

The directory structure for our application looks like this:

./
docker-compose.yml (compose file of Postgis and FastAPI)
Dockerfile (for FastAPI Dockerfile)
requirements.txt (python app requirements)
.env
.env.sample
./snippets
config.py (configuration of environment/application variables)
crud.py (functions to interact with the database)
db.py (database connections and metadata)
gunicorn_config.py (configuration of gunicorn)
models.py (database models)
server.py (FastAPI server and routes)
./templates
index.html (HTML template with MapboxGJ to view vector tiles)
./scripts
run.sh (helper script to launch docker compose)
./postgres
./initdb.d (scripts run on startup of PostGIS docker image)
00-initdb.sql (initialize database)
01-create-table.sql (create tables)
02-load-data.sql (load data in ./data dir)
03-create-indexes.sql (create indexes)
./data (Airbnb listing data)
listings_1.csv.gz
listings_2.csv.gz
...

We follow the best practices for initializing the database, creating tables, loading data, and creating indexes using the mapped volume ./postgres/initdb.d. The scripts in this directory will be executed in alphanumeric order when the Postgres container starts.

In our case this will create a table listing with the columns listing_id, latitude, longitude and geometry(with SRID 4326). Other attributes about the listing such as nightly rate, number of bedrooms are also stored in the table.

+----------------------+------------------+------------------+------------------------------------------+-----------------+---------------------+--------------+------------------+---------------+----------+----------------+----------------+----------------+------------+--------------------+------------------+-------------------+----------------+-----------------------+------------+------------+------------------------+-------------+---------------+---------------+------------------------+---------------+-----------------+--------------------------------------------------+---------------------------------------------------------------------------------------------------------------------+-------------------+
| "listing_id" | "longitude" | "latitude" | "geometry" | "property_type" | "property_sub_type" | "room_count" | "bathroom_count" | "sleep_count" | "rating" | "review_count" | "instant_book" | "is_superhost" | "is_hotel" | "children_allowed" | "events_allowed" | "smoking_allowed" | "pets_allowed" | "has_family_friendly" | "has_wifi" | "has_pool" | "has_air_conditioning" | "has_views" | "has_hot_tub" | "has_parking" | "has_patio_or_balcony" | "has_kitchen" | "nightly_price" | "name" | "main_image_url" | "recently_active" |
+----------------------+------------------+------------------+------------------------------------------+-----------------+---------------------+--------------+------------------+---------------+----------+----------------+----------------+----------------+------------+--------------------+------------------+-------------------+----------------+-----------------------+------------+------------+------------------------+-------------+---------------+---------------+------------------------+---------------+-----------------+--------------------------------------------------+---------------------------------------------------------------------------------------------------------------------+-------------------+
| "5985872" | 1.40467139384128 | 38.7359128252745 | POINT(1.40467139384128 38.7359128252745) | entire_home | apartment | 2 | 1 | 4 | 100.0 | 2 | false | true | false | false | true | true | false | false | true | true | true | false | true | true | false | true | | Velero Moskito Valiente | | false |
| "4291324" | 1.40649931655283 | 38.7297219993439 | POINT(1.40649931655283 38.7297219993439) | private_room | | 1 | 1 | 2 | | 0 | false | false | false | false | true | true | false | false | true | true | true | false | true | true | false | true | | Primera Linea! Entre MAR y LAGO! | | false |
| "7565940" | 1.40906888012791 | 38.7252687105131 | POINT(1.40906888012791 38.7252687105131) | entire_home | boat | 1 | 1 | 2 | 90.0 | 5 | true | false | false | false | false | false | false | false | false | false | false | false | false | false | false | true | | Velero 2 pax en Formentera | | false |
| "10826946" | 1.40385555799523 | 38.7293076206739 | POINT(1.40385555799523 38.7293076206739) | private_room | boat | 1 | 2 | 4 | | 0 | false | false | false | false | false | false | false | false | false | false | false | false | false | false | false | false | | Formentera on Santippe | | false |
| "17984038" | 1.40778079793859 | 38.7269515269832 | POINT(1.40778079793859 38.7269515269832) | entire_home | boat | 1 | 0 | 2 | | 1 | true | false | false | false | false | false | false | false | false | false | false | false | false | false | false | false | | Duerme y navega con patrón en velero para 2 | | false |
| "4005484" | 1.40979974019137 | 38.7278542479708 | POINT(1.40979974019137 38.7278542479708) | entire_home | boat | 1 | 0 | 2 | 90.0 | 22 | true | false | false | false | true | false | false | false | false | false | false | false | false | false | false | false | | Velero en Formentera. Stay on board 2 pax. | | false |
| "35179794" | 1.40003 | 38.7256 | POINT(1.40003 38.7256) | entire_home | villa | 5 | 3 | 10 | | 0 | true | false | false | false | false | true | false | false | true | false | true | false | false | true | false | true | | Villa Sa Voliaina - Estany des Peix - Formentera | | false |
| "19896852" | 1.40374 | 38.72435 | POINT(1.40374 38.72435) | entire_home | house | 3 | 2 | 6 | 100.0 | 13 | false | false | false | false | false | true | false | false | true | false | true | false | false | true | false | true | | House with terraces in the lake ET 0502 | | false |
| "35252525" | 1.40257 | 38.72596 | POINT(1.40257 38.72596) | entire_home | villa | 5 | 3 | 10 | 80.0 | 4 | true | false | false | true | false | true | true | true | true | false | true | false | false | true | true | true | 323 | Villa Sa Voliaina - Estany des Peix | https://a0.muscache.com/im/pictures/prohost-api/Hosting-35252525/original/c20d1747-f620-428b-a5ab-7c3a49fd8c6d.jpeg | true |
| "861235572893488501" | 1.4104147 | 38.7306989 | POINT(1.4104147 38.7306989) | entire_home | boat | 2 | 2 | 4 | | 0 | false | false | false | false | false | false | false | false | false | false | false | false | false | false | false | true | 859 | Preciós veler de 14 metres | https://a0.muscache.com/im/pictures/4cdadb30-65a9-45e1-af2f-4d15d5714542.jpg | true |
+----------------------+------------------+------------------+------------------------------------------+-----------------+---------------------+--------------+------------------+---------------+----------+----------------+----------------+----------------+------------+--------------------+------------------+-------------------+----------------+-----------------------+------------+------------+------------------------+-------------+---------------+---------------+------------------------+---------------+-----------------+--------------------------------------------------+---------------------------------------------------------------------------------------------------------------------+-------------------+

The Postgres start up process will then load in the data and create indexes on the tables so its ready for the FastAPI service to query.

In total, our docker-compose file will look like this:

services:
postgres:
profiles: ["dev"]
image: postgis/postgis:16-3.4-alpine
restart: always
healthcheck:
test:
- CMD-SHELL
- pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB
interval: 5s
timeout: 5s
retries: 5
env_file: ".env"
ports:
- 5432:5432
expose:
- 5432
volumes:
- ./postgres/initdb.d/:/docker-entrypoint-initdb.d/
- pgdata-dev:/var/lib/postgresql/data
networks:
- dev

app:
profiles: ["dev"]
build:
context: .
command: >
bash -c "
gunicorn snippets.server:app --config ./snippets/gunicorn_config.py"
env_file: ".env"
environment:
- POSTGRES_HOST=postgres
volumes:
- ./:/code
ports:
- 8000:8000
expose:
- 8000
depends_on:
postgres:
condition: service_healthy
networks:
- dev

volumes:
pgdata-dev:

networks:
dev:

PostGIS and the Tile Query

To fetch vector tiles from the listing data we require a few PostGIS functions:

  • ST_TileEnvelope(z, x, y) to generate the bounding box of a specific tile at zoom level z and coordinates x and y. This returns a geometry in Web Mercator SRID 3857.
  • ST_Transform(geometry, SRID)to transform the tile envelope to SRID 4326, and to later transform the listing geometries to SRID 3857 as this is the standard for vector tiles.
  • ST_Intersects(geometry_a, geometry_b)to filter the listings that intersect the tile envelope.
  • ST_AsMVTGeom(geometry, bounds_geometry) to create the vector tile geometry from the listing geometries by clipping the geometries to the tile envelope, simplifying the geometries that will not be visible at the tile resolution, and translate from cartesian (geometry coordinates) to image coordinates (tile coordinates)
  • ST_AsMVT(rows) to create the Mapbox Vector Tile from the clipped and simplified geometries, along with any attributes we want to include in the tile, returning the tile as bytes.

Using these functions we can create a generate SQL query to turn our listing table into tile for (z, x, y) triplets:

With tile_bounds_cte AS (
-- work out the bounds of the tile in 3857 and 4326
SELECT
ST_TileEnvelope(:z, :x, :y) AS geom_3857,
ST_Transform(ST_TileEnvelope(:z, :x, :y), 4326) AS geom_4326
), mvt_table_cte AS (
-- process each listing in the tile bounds
SELECT
ST_AsMVTGeom(
ST_Transform(listing.geometry, 3857), tile_bounds_cte.geom_3857
) AS geom,
listing.listing_id,
listing.longitude,
listing.latitude,
listing.property_type,
-- other attribute columns ...
FROM
tile_bounds_cte,
properties
WHERE
ST_Intersects(listing.geometry, tile_bounds_cte.geom_4326)
)
-- package all the listings in the tile into a single MVT
SELECT
ST_AsMVT(mvt_table_cte.*) AS mvt
FROM
mvt_table_cte;

This query will return a single row with a single column that contains the vector tile as bytes and includes all the listing attributes within the tile so that we can dynamically use them in the client to style and filter.

Serving the Tiles with FastAPI

To server the vector tiles we’ll use FastAPI and Async SQLAlchemy.

Firstly, let’s install the dependencies:

sqlalchemy==2.0.31
geoalchemy2==0.15.2
psycopg2-binary==2.9.9
asyncpg==0.29.0
pydantic==2.8.2
pydantic-settings==2.3.4
fastapi==0.111.1
uvicorn==0.30.1
gunicorn==22.0.0
jinja2==3.1.4
orjson==3.10.5
aiocache==0.12.2

Next, define the SQLAlchemy models using GeoAlchemy2:

from typing import Any

from geoalchemy2 import Geometry
from sqlalchemy import Computed, Index
from sqlalchemy.orm import Mapped, mapped_column

from snippets.db import Base


class Listing(Base):
__tablename__ = "listing"
__table_args__ = (Index(None, "geometry", postgresql_using="gist"),)

listing_id: Mapped[str] = mapped_column(primary_key=True)
longitude: Mapped[float] = mapped_column(nullable=True)
latitude: Mapped[float] = mapped_column(nullable=True)
geometry: Mapped[Any] = mapped_column(
Geometry(
"POINT",
srid=4326,
spatial_index=False,
from_text="ST_GeogFromText",
name="geometry",
),
Computed(
"""
public.geometry(point, 4326) GENERATED ALWAYS AS (
st_setsrid(st_makepoint(longitude, latitude), 4326)
)
""",
persisted=True,
),
nullable=True,
)
property_type: Mapped[str] = mapped_column(nullable=True, index=True)
# other attribute columns ...

We now next to translate the SQL query from above into a Python function to fetch the tile bytes:

from aiocache import Cache, cached
from aiocache.serializers import PickleSerializer
from geoalchemy2.functions import (
ST_AsMVT,
ST_AsMVTGeom,
ST_Intersects,
ST_TileEnvelope,
ST_Transform,
)
from sqlalchemy import and_, select, text
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.sql.expression import literal

from snippets.models import Listing


@cached(
ttl=600,
cache=Cache.MEMORY,
serializer=PickleSerializer(),
)
async def get_listing_tiles_bytes(
session: AsyncSession,
z: int,
x: int,
y: int,
recently_active: bool | None = True,
) -> bytes | None:
tile_bounds_cte = select(
ST_TileEnvelope(z, x, y).label("geom_3857"),
ST_Transform(ST_TileEnvelope(z, x, y), 4326).label("geom_4326"),
).cte("tile_bounds_cte")

mvt_table_cte = (
select(
ST_AsMVTGeom(
ST_Transform(Listing.geometry, 3857), tile_bounds_cte.c.geom_3857
).label("geom"),
Listing.listing_id,
Listing.longitude,
Listing.latitude,
Listing.property_type,
# other attribute columns ...
)
.select_from(Listing)
.join(tile_bounds_cte, literal(True)) # cross join
.filter(
and_(
Listing.geometry.is_not(None),
ST_Intersects(Listing.geometry, tile_bounds_cte.c.geom_4326),
)
)
)

if recently_active is not None:
mvt_table_cte = mvt_table_cte.filter(
Listing.recently_active == recently_active
)

mvt_table_cte = mvt_table_cte.cte("mvt_table_cte")
stmt = select(ST_AsMVT(text("mvt_table_cte.*"))).select_from(mvt_table_cte)
result = await session.execute(stmt)
return result.scalar()

You can see here how easily it is to cache the results of the query using aiocache. Here we are just using a simple in-memory cache, but you could easily use Redis cache to be shared across multiple instances of the FastAPI service.

Finally, we can define the FastAPI endpoint to serve the vector tiles:

import gzip

from fastapi import Depends, FastAPI, Path, Request, Response, status
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from sqlalchemy.ext.asyncio import AsyncSession

from snippets.config import config
from snippets.crud import get_listing_tiles_bytes
from snippets.db import get_session

templates = Jinja2Templates(directory="./snippets/templates")


async def tile_args(
z: int = Path(..., ge=0, le=24),
x: int = Path(..., ge=0),
y: int = Path(..., ge=0),
) -> dict[str, str]:
return dict(z=z, x=x, y=y)


app = FastAPI(title=config.PROJECT_NAME, version=config.VERSION)


@app.get("/", response_class=HTMLResponse)
async def homepage(request: Request):
return templates.TemplateResponse(
request=request,
name="index.html",
context={
"title": config.PROJECT_NAME,
"host": "localhost",
"port": config.SNIPPETS_PORT,
"mapbox_access_token": config.MAPBOX_ACCESS_TOKEN,
},
)


@app.get(
"/listings/tiles/{z}/{x}/{y}.mvt",
summary="Get listingtiles",
)
async def get_listing_tiles(
tile: dict[str, int] = Depends(tile_args),
session: AsyncSession = Depends(get_session),
) -> Response:
byte_tile = await get_listing_tiles_bytes(
session=session, recently_active=True, **tile
)
byte_tile = b"" if byte_tile is None else byte_tile
return Response(
content=gzip.compress(byte_tile),
media_type="application/vnd.mapbox-vector-tile",
headers={"Content-Encoding": "gzip"},
status_code=status.HTTP_200_OK,
)

There are two routes here:

  • / to serve a HTML template with the Mapbox GL JS map to view the tiles. See below for the template.
  • /listings/tiles/{z}/{x}/{y}.mvt to serve the vector tiles, and is called by the template served on the / route. To help with network speed we also gzip compress the responses.

A Very Bad Frontend

The template consists of a simple HTML page with Mapbox GL JS to display the vector tiles.

On the side of a page there is a simple slider that allows you to filter listings in the map by the nightly_rate attribute.

THIS IS THE BEAUTY OF VECTOR TILES! 😍

Instead of having to query the server for new data, we can simply filter the data on the client side and only display the data we want.

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>{{ title }}</title>
<meta
name="viewport"
content="initial-scale=1,maximum-scale=1,user-scalable=no"
/>
<script src="https://api.mapbox.com/mapbox-gl-js/v3.5.1/mapbox-gl.js"></script>
<link
href="https://api.mapbox.com/mapbox-gl-js/v3.5.1/mapbox-gl.css"
rel="stylesheet"
/>
<style>
/* lots of boring and bad CSS */
</style>
</head>
<body>
<div id="map"></div>
<div id="filterContainer">
<h1>Listing Filters</h1>
<h2>Max Price $</h2>
<input
type="range"
id="priceSlider"
min="10"
max="2000"
step="10"
value="2000"
/>
<label for="priceSlider"
>Nightly Price: <span id="priceValue">$2000</span></label
>
</div>
<script>
const host = "{{ host }}";
const port = "{{ port }}";
const tileEndpoint = `http://${host}:${port}`;

// function to create the html for an Airbnb style listing card popup
const createAirbnbCardPopup = (
listing_id,
imageUrl,
name,
propertyType,
subPropertyType,
beds,
rating,
reviewCount,
price
) => {
return `
<div class="listing-card">
<a href="https://www.airbnb.com/rooms/${listing_id}" target="_blank">
<img src="${imageUrl}" alt="${name}" class="listing-image" />
</a>
<div class="listing-details">
<h3 class="listing-name">${name}</h3>
<p class="listing-type">${propertyType} - ${subPropertyType}</p>
<div class="listing-rating">
<span class="rating">${rating}</span>
<span class="review-count">(${reviewCount} reviews)</span>
</div>
<div class="listing-price">$${price} night</div>
</div>
</div>
`;
};

// set the mapbox token and base map
mapboxgl.accessToken = "{{ mapbox_access_token }}";
var map = new mapboxgl.Map({
container: "map",
style: "mapbox://styles/mapbox/streets-v11",
zoom: 8,
center: [2.4854125072596753, 39.36600687051655],
});

map.on("load", () => {
// add the listingtile source from our FastAPI tile server
const listingUrl = `${tileEndpoint}/listings/tiles/{z}/{x}/{y}.mvt`;
map.addSource("listingTileSource", {
type: "vector",
tiles: [listingUrl],
minzoom: 8,
maxzoom: 24,
});

// add a circle style layer for the listings
map.addLayer({
id: "listingCircleLayer",
type: "circle",
source: "listingTileSource",
"source-layer": "default",
paint: {
"circle-color": "#d6e6ff",
"circle-opacity": 0.9,
"circle-radius": 20,
"circle-stroke-color": "#515459",
},
minzoom: 10,
maxzoom: 24,
});

// add a text style layer for the nightly_rate of listings
map.addLayer({
id: "listingTileLayer",
type: "symbol",
source: "listingTileSource",
"source-layer": "default",
layout: {
"text-field": ["get", "nightly_price"],
"text-font": ["Open Sans Bold", "Arial Unicode MS Bold"],
"text-size": 12,
"text-offset": [0, -0.5],
"text-anchor": "top",
"icon-image": ["get", "marker-icon"],
},
paint: {
"text-color": "#000000",
"text-halo-color": "#FFFFFF",
"text-halo-width": 2,
},
minzoom: 10,
maxzoom: 24,
});

// add a Airbnb style listingcard popup
map.on("click", "listingCircleLayer", (e) => {
map.flyTo({
center: e.features[0].geometry.coordinates,
});
const markerData = e.features[0].properties;
const popHtml = createAirbnbCardPopup(
markerData.listing_id,
markerData.main_image_url,
markerData.name,
markerData.property_type,
markerData.property_sub_type,
markerData.beds,
markerData.rating,
markerData.review_count,
markerData.nightly_price
);
const popup = new mapboxgl.Popup({ offset: 25 })
.setLngLat(e.features[0].geometry.coordinates)
.setHTML(popHtml)
.addTo(map);
});
map.on("mouseenter", "listingCircleLayer", () => {
map.getCanvas().style.cursor = "pointer";
});
map.on("mouseleave", "listingCircleLayer", () => {
map.getCanvas().style.cursor = "";
});
});

map.addControl(new mapboxgl.NavigationControl());

// here we add an event listener to filter the listings by price dynamically
document
.getElementById("priceSlider")
.addEventListener("input", function (e) {
const sliderValue = e.target.value;
document.getElementById("priceValue").innerText = sliderValue;
filterFeaturesByPrice(sliderValue);
});

function filterFeaturesByPrice(price) {
map.setFilter("listingCircleLayer", [
"<",
["get", "nightly_price"],
parseInt(price),
]);
map.setFilter("listingTileLayer", [
"<",
["get", "nightly_price"],
parseInt(price),
]);
}
</script>
</body>
</html>

As we are using Airbnb listings, the map is supposed to mimic the Airbnb website, but it’s a very bad copy. However, the functionality is there:

  • as you zoom in and get to zoom level 10, you’ll see the listings appear on the map.
  • each listing contains all the attributes we queried from the Postgres database, and you can filter the listings by the nightly rate using the slider.
  • you can also use the attributes to style the listings on the map.
  • for example, each listing is represented by a circle with the nightly rate as the label.
  • if you click on a listing, you’ll get a popup with listing information, where the attributes are rendered dynamically into the HTML.

The Final Results

To launch our Postgres database and FastAPI service, we can use the following script:

#!/bin/bash

set -e

ENV=$1
shift
DOCKER_ARGS="$*"

trap ctrl_c INT
trap ctrl_c SIGTERM

function ctrl_c() {
echo "Gracefully hutting down containers ..."
docker compose --profile $ENV down --volumes
exit 0
}

docker compose --profile $ENV up $DOCKER_ARGS

This will start the Postgres service, run all the scripts in ./postgres/initdb.d, and then start the FastAPI service once the Postgres service is healthy. To kill the service, simply hit ctrl+c and the script will gracefully shut down the containers and remove the data volume.

To run the script, use the following command:

./scripts/run.sh dev

To view the map, open a browser and navigate to http://localhost:8000/ and Voila!

Note the docker compose logs as you zoom and pan around the map, you’ll see the FastAPI service serving the vector tiles dynamically!

Extensions and Improvements

This is a very basic implementation of a vector tile server, but there are many ways to improve and extend it:

  • Redis Cache: instead of using an in-memory cache, you could use Redis to cache the tiles across multiple instances of the FastAPI service.
  • Dynamic Queries: say you run a business that has many clients, each with their own geospatial data. For example, you want to serve only the vector tiles for the data that belongs to that client. To do this you could set up the route as http://localhost:8000/{client_uuid}/listings/tiles/{z}/{x}/{y}.mvt and filter the data on the server before returning it to the client.
  • You could further filter the data on the client side by adding query string parameters to the tile endpoint, for example http://localhost:8000/listings/tiles{z}/{x}/{y}.mvt?price_max=500&beds=2 and pushing the filters down to the SQL query.
  • The SQL query is just that, a SQL query. So feel free to do joins, filters, group by, partition the tables, … You wrote the entire code so you can do want you want with it!

If you enjoyed this article or have any feedback please feel free to clap/follow and check out the original source code GitHub.

Thanks for reading!

--

--