Serve Dynamic Vector Tiles in an ExpressJS Web Application

A little how-to guide for creating vector tiles using pg_tileserv and integrating them into a simple LeafletJS frontend

Aditya Sharma
6 min readDec 25, 2022

Background

Vector tiles are an efficient and flexible way to represent and render large spatial datasets on the web, and can be a useful tool for creating custom maps and visualizations from your data. They represent spatial data as vector graphics in a tiled format, which can then be rendered on a map or a 3D scene. Vector graphics are resolution-independent, meaning they can be rendered at any scale without loss of quality. This can be particularly useful for web applications that need to support a wide range of zoom levels and screen resolutions. This allows for more efficient rendering and manipulation of large datasets compared to traditional raster tiles, as the data is stored as vectors rather than pixels.

pg_tileserve is a PostGIS-only tile server created using Go language, which allows you to create and serve vector tiles from a PostgreSQL database directly. It can be used to create and serve vector tiles from a wide variety of data sources, including spatial data stored in PostGIS, geojson data, and other vector data formats. It uses the Mapbox Vector Tile (MVT) specification to encode the data, and provides an HTTP API for serving the tiles. The output file hierarchy will contain a metadata.json file at its root and {z}/{x}/{y}.pbf files with the tiles, where z is the zoom level and (x, y) the coordinate of the tile in a given zoom level. The origin of the tiling system is the top-left tile (XYZ convention).

Backend: Creating and Serving Vector Tiles

Download buildings footprint dataset using R script — I will use building data for the state of Alabama, which is publicly available via GitHub. You can either use this script or just download using the link directly and unzip in your working directory. I prefer using a script so that I can set it up to programatically download and extract in the desired directory.

# Download and Extract Building Footprints GeoJSON

library(downloader)

buildings.url <- "https://usbuildingdata.blob.core.windows.net/usbuildings-v2/Alabama.geojson.zip"

destFileName <- sapply(strsplit(buildings.url, "/"), tail, 1) # get the name from the download url

main_dir <- getwd() # setting up the main directory
data_dir <- "data" # setting up the data directory

if (file.exists(data_dir)) {
setwd(file.path(main_dir, data_dir)) # specifying the working directory
} else {
dir.create(file.path(main_dir, data_dir)) # create a new sub directory inside
setwd(file.path(main_dir, data_dir)) # specifying the working directory

# check if the file is already present inside the data folder
if (file.exists(destFileName)) {
print("Zip file already exists! Unzip and delete operations underway :)")
# Unzip first and delete the zip file
unzip(destFileName, exdir = "./")
unlink(destFileName)
} else {
print("Downloading data...")
# Download the file inside the data folder, unzip, and then delete the zip
downloader::download(url = buildings.url, dest = destFileName, mode = "wb")
print("Extracting data...")
unzip(destFileName, exdir = "./")
print("Deleting zip file")
unlink(destFileName)
}
}

Create a PostgreSQL Database with PostGIS extension — Once we have download the building footprints dataset, we will setup a PostgreSQL database called buildings with PostGIS extension enabled. I have used a bash script to use command line tools createdb to initialise a new database and psql to enable PostGIS extension.

#!/bin/bash

# Set PostGIS connection string
user="postgres"
port=5432
dbname="buildings"
host="localhost"

# Create Database
createdb -U $user -h $host -p $port $dbname

# Add PostGIS extension to database
psql -U $user -h $host -p $port -d $dbname -c "CREATE EXTENSION postgis;"

Import data into PostgreSQL database — Next, we will use the ogr2ogr command line utility tool to import building footprint data into the buildings database. The bash script loops through the data directory and imports all geojson files present inside it.

#!/bin/bash

# Data Directory
search_dir=./data

# Set PostGIS connection string
user="postgres"
port=5432
password= "password"
dbname="buildings"
host="localhost"

# Loop through GeoJSON files in the data directory and Ingest them to Buildings Database
for entry in "$search_dir"/*.geojson
do
echo "Importing GeoJSON: $entry"

# Use ogr2ogr to ingest GeoJSON into PostGIS
ogr2ogr -f "PostgreSQL" PG:"dbname=$dbname user=$user host=$host port=$port password=$password" "$entry"

# Process Complete
echo "Import Complete"
done

Create Vector Tiles from the PostGIS backend using pg_tileserv — to serve the tiles, you can use the pg_tileserve command-line utility, which starts an HTTP server that serves the tiles from the tile table. You can then use a map client, such as Mapbox GL JS or OpenLayers, to display the tiles on a map.

  • To install pg_tileserv, download the binary file from Github Repository. Unzip the file, copy the pg_tileserv binary wherever you wish, or use it in place. If you move the binary, remember to move the assets/ directory to the same location, or start the server using the AssetsDir configuration option. Copy the configuration template inside pg_tileserv/config directory and change the settings accordingly. In my case, I had to tweak DbConnection, AssetsPath, and MaxFeaturesPerTile options.
#!/bin/bash

# Set PostGIS connection string
user="postgres"
port=5432
password="password"
dbname="buildngs"
host="localhost"

# Set PostGIS connection string for pg_tileserv
export DATABASE_URL=postgres://$user:$password@$host:$port/$dbname

# Start pg_tileserv using the edited configuration file
./pg_tileserv/pg_tileserv --config ./pg_tileserv/config/pg_tileserv.toml

At this point, you should be able to navigate to localhost:7800 and see the pg_tileserv frontend, where you can verify if the vector tiles have been generated as expected.

Front-end: Integrating Vector Tiles into a LeafletJS Web Map

Create a new project directory for your web app and navigate to it in your terminal. Setup an empty file-folder structure for your expressJS Web App.

# Navigate to Project Folder using the command line termina
cd <ProjectDirPath>

# Create a directory called web_app inside the project folder directory
mkdir web_app

# Create an app.js file in your project directory, which will contain the code for your web app
cd web_app
touch app.js

# Create a public directory inside web_app
mkdir public
cd public

# Create index.html file where the frontend HTML code will reside
touch index.html

Initialise a new Node.js project by running npm init command. This will create a package.json file in your project directory, which will contain metadata about your project and a list of dependencies.

cd <ProjectDir>/web_app
npm init -y

Install ExpressJS and LeafletJS as dependencies for your project

npm install express
npm install leaflet

Create an app.js file in your project directory, which will contain the code for your web app. In this file, you will need to require the ExpressJS and set up the basic structure for your web app.

In your app.js, use the express.static() middleware function to serve static files from the public folder.

app.use(express.static('public'));

The index.html inside the public directory will be the homepage for our web app. To do so, make sure you have an index.html file in the public folder of your ExpressJS app. Next, you can create a route in app.js that sends the index.html file as the response to a GET request.

app.get('/', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'index.html'));
});

This will send the index.html file as the response to a GET request to the root URL (http://localhost:3000/).

All things considered, app.js should look like this:

const express = require('express');
const app = express();
const port = 3000;

app.use(express.static('public'));

app.get('/', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'index.html'));
});

app.listen(port, () => console.log(`Building Footprints Web App: ${port}!`));

You can use the LeafletJS library to create a map by including the following code in your public/index.html file:

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Buildings Footprint App</title>

<!-- CSS for Leaflet map -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.5.1/dist/leaflet.css"
integrity="sha512-xwE/Az9zrjBIphAcBb3F6JVqxf46+CDLwfLMHloNu6KEQCAWi6HcDUbeOfBIptF7tcCzusKFjFw2yuvEpDL9wQ=="
crossorigin=""/>

<!-- JS for Leaflet map -->
<!-- Make sure you put this AFTER Leaflet's CSS -->
<script src="https://unpkg.com/leaflet@1.5.1/dist/leaflet.js"
integrity="sha512-GffPMF3RvMeYyc1LWMHtK8EbPv0iNZ8/oTtHPx9/cc2ILxQ+u905qIwdpULaqDkyBKgOaB57QTMg7ztg8Jm2Og=="
crossorigin=""></script>

<!-- Leaflet plugin for vector tiles support -->
<script type="text/javascript" src="https://unpkg.com/leaflet.vectorgrid@1.2.0"></script>

<style>
html, body, #map {
font-family: sans-serif;
height: 100%;
width: 100%;
}
body {
padding: 0;
margin: 0;
}
#map {
z-index: 1;
}
#meta {
background-color: rgba(255,255,255,0.75);
color: black;
z-index: 2;
position: absolute;
top: 10px;
left: 20px;
padding: 10px 20px;
margin: 0;
}
</style>
</head>

<body>

<div id="map"></div>

<script>

var map = L.map('map').setView([35, -90], 7);

// Add a base map layer to the map
var baseUrl = "https://tile.openstreetmap.org/{z}/{x}/{y}.png";
var baseLayer = L.tileLayer(baseUrl, {
"maxZoom": 18
});
baseLayer.addTo(map);

// Add the tile layer to the map
// https://localhost:7800/public.alabama/{z}/{x}/{y}/pbf
var vectorServer = "http://localhost:7800/";
var vectorLayerId = "public.alabama";
var vectorUrl = vectorServer + vectorLayerId + "/{z}/{x}/{y}.pbf";

var vectorTileStyling = {};
var vectorTileColor = "red";

vectorTileStyling[vectorLayerId] = {
"fill": true,
"fillColor": vectorTileColor,
"fillOpacity": 0.5,
"color": vectorTileColor,
"opacity": 0.7,
"weight": 0.2
};

var vectorTileOptions = {
"rendererFactory": L.canvas.tile,
"attribution": "&copy; <a href='http://www.openstreetmap.org/copyright'>OpenStreetMap</a> contributors, made with Natural Earth",
"vectorTileLayerStyles": vectorTileStyling
};

var vectorLayer = L.vectorGrid.protobuf(vectorUrl, vectorTileOptions).addTo(map);

</script>

</body>
</html>

Navigate to the project directory for your ExpressJS app in your terminal and start the app.

node app.js

The app will start running and you should see a message indicating that the app is listening on a specific port. You can access the app in your web browser by going to http://localhost:3000, where 3000 is the port number specified in the app.jsfor your web app.

Resources and Further Reading

  1. https://github.com/microsoft/USBuildingFootprints
  2. https://gdal.org/drivers/vector/mvt.html
  3. https://access.crunchydata.com/documentation/pg_tileserv/latest/pdf/pg_tileserv.pdf
  4. https://github.com/CrunchyData/pg_tileserv

--

--

Aditya Sharma

Geoscientist and data science enthusiast interested in applied use of remote sensing satellites.