A light and quick nodejs webserver for serving mapbox vector tiles (and topojson) from a postgis backend

Usage no npm install needed!

<script type="module">
  import tilesplash from 'https://cdn.skypack.dev/tilesplash';



A light and quick nodejs webserver for serving topojson and mapbox vector tiles from a postgis backend. inspired by Michal Migurski's TileStache. Works great for powering Mapbox-GL-based apps like this:



Tilesplash depends on node and npm


npm install tilesplash


Here's a simple tile server with one layer

var Tilesplash = require('tilesplash');

// invoke tilesplash with DB options
var app = new Tilesplash({
  user: myUser,
  password: myPassword,
  host: localhost,
  port: 5432,
  database: myDb

// define a layer
app.layer('test_layer', function(tile, render){
  render('SELECT ST_AsGeoJSON(the_geom) as the_geom_geojson FROM layer WHERE ST_Intersects(the_geom, !bbox_4326!)');

// serve tiles at port 3000
  • Topojson tiles will be available at http://localhost:3000/test_layer/{z}/{x}/{y}.topojson
  • Mapbox vector tiles will be available at http://localhost:3000/test_layer/{z}/{x}/{y}.mvt

(See client implementation examples below, and complete demo implementation in demo/)


new Tilesplash(connection_details, [cacheType])

creates a new tilesplash server using the given postgres database

var dbConfig = {
  user: username,
  password: password,
  host: hostname,
  port: 5432,
  database: dbname

var app = new Tilesplash(dbConfig);

To cache using redis, pass 'redis' as the second argument. Otherwise an in-process cache will be used.


an express object, mostly used internally but you can use it to add middleware for authentication, browser caching, gzip, etc.

Tilesplash.layer(name, [middleware, ...], [mvtOptions], callback)

name: the name of your layer. Tiles will be served at /name/z/x/y.topojson

middleware: a middleware function

mvtOptions: optional mapnik parameters, e.g. { strictly_simple: true }

callback: your tile building function with the following arguments. function(tile, render)

Simple layer

This layer renders tiles containing geometry from the the_geom column in test_table

app.layer('simpleLayer', function(tile, render){
  render('SELECT ST_AsGeoJSON(the_geom) as the_geom_geojson FROM test_table WHERE ST_Intersects(the_geom, !bbox_4326!)');

Combined layers

Tilesplash can render tiles from multiple queries at once

app.layer('multiLayer', function(tile, render){
    circles: 'SELECT ST_AsGeoJSON(the_geom) as the_geom_geojson FROM circles WHERE ST_Intersects(the_geom, !bbox_4326!)',
    squares: 'SELECT ST_AsGeoJSON(the_geom) as the_geom_geojson FROM squares WHERE ST_Intersects(the_geom, !bbox_4326!)'

Using mapnik geometry parameters

This layer renders tiles containing geometry features simplified to a threshold of 4. Full parameters are documented here.

app.layer('simpleLayer', { simplify_distance: 4 }, function(tile, render){
  render('SELECT ST_AsGeoJSON(the_geom) as the_geom_geojson FROM test_table WHERE ST_Intersects(the_geom, !bbox_4326!)');

Escaping variables

Tilesplash has support for escaping variables in sql queries. You can do so by passing an array instead of a string wherever a sql string is accepted.

app.layer('escapedLayer', function(tile, render){
  render(['SELECT ST_AsGeoJSON(the_geom) as the_geom_geojson FROM points WHERE ST_Intersects(the_geom, !bbox_4326!) AND state=$1', 'California']);

app.layer('escapedMultiLayer', function(tile, render){
    hotels: ['SELECT ST_AsGeoJSON(the_geom) as the_geom_geojson FROM hotels WHERE ST_Intersects(the_geom, !bbox_4326!) AND state=$1', 'California'],
    restaurants: ['SELECT ST_AsGeoJSON(the_geom) as the_geom_geojson FROM restaurants WHERE ST_Intersects(the_geom, !bbox_4326!) AND state=$1', 'California']

Restricting zoom level

Sometimes you only want a layer to be visible on certain zoom levels. To do that, we simply render an empty tile when tile.z is too low or too high.

app.layer('zoomDependentLayer', function(tile, render){
  if (tile.z < 8 || tile.z > 20) {
    render.empty(); //render an empty tile
  } else {
    render('SELECT ST_AsGeoJSON(the_geom) as the_geom_geojson FROM points WHERE ST_Intersects(the_geom, !bbox_4326!)');

You can also adapt your layer by zoom level to show different views in different situations.

In this example we show data from the heatmap table when the zoom level is below 8, data from points up to zoom 20, and empty tiles when you zoom in further than that.

app.layer('fancyLayer', function(tile, render){
  if (tile.z < 8) {
    render('SELECT ST_AsGeoJSON(the_geom) as the_geom_geojson FROM heatmap WHERE ST_Intersects(the_geom, !bbox_4326!)');
  } else if (tile.z > 20) {
  } else {
    render('SELECT ST_AsGeoJSON(the_geom) as the_geom_geojson FROM points WHERE ST_Intersects(the_geom, !bbox_4326!)');


Middleware allows you to easily extend tilesplash to add additional functionality. Middleware is defined like this:

var userMiddleware = function(req, res, tile, next){
  tile.logged_in = true;
  tile.user_id = req.query.user_id;

You can layer include this in your layers

app.layer('thisOneHasMiddleware', userMiddleware, function(tile, render){
  if (!tile.logged_in) {
  } else {
    render(['SELECT ST_AsGeoJSON(the_geom) as the_geom_geojson FROM placesVisited WHERE ST_Intersects(the_geom, !bbox_4326!) AND visitor=$1', tile.user_id]);

Middleware can be synchronous or asynchronous, just be sure to call next() when you're done!


tile is a parameter passed to middleware and layer callbacks. It is an object containing information about the tile being requested. It will look something like this:

  x: 100,
  y: 100,
  z: 10,
  bounds: [w, s, e, n] //output from SphericalMercator.bounds(x,y,z) using https://github.com/mapbox/node-sphericalmercator
  bbox: 'BBOX SQL for webmercator',
  bbox_4326: 'BBOX SQL for 4326 projection' //you probably need this

Anything in tile can be substituted into your SQL query by wrapping it in exclamation marks like !this!

You can add custom items into tile like so:

tile.table = "states";
render('SELECT ST_AsGeoJSON(the_geom) as the_geom_geojson FROM !table! WHERE !bbox!')

Note that when you interpolate tile variables into your queries with the exclamation point syntax, that data will not be escaped. This allows you to insert custom SQL from tile variables, like with !bbox!, but it can be a security risk if you allow any user input to be interpolated that way.

When you want to use user input in a query, see Escaping variables above.


render is the second argument passed to your layer callback function. You can use it to render different kinds of tiles.


Runs a SQL query and displays the result as a tile


Runs multiple SQL queries and renders them in seperate topojson layers. See Combined layers above.


Alias of render()


Use this if your SQL is really long and/or you want to keep it seperate.

app.layer('complicatedLayer', function(tile, render){


Renders an empty tile


Replies with a 500 error

render.raw(string or http code)

Sends a raw reply. I can't think of any reason you would want to do this, but feel free to experiment.

app.layer('smileyLayer', function(tile, render){
app.layer('notThereLayer', function(tile, render){


Replies with the specified file

app.layer('staticLayer', function(tile, render){


Caching is very important. By default, Tilesplash uses an in-memory cache. You can use redis instead by passing 'redis' as the second argument when initializing a Tilesplash server.

There are two ways to implement caching. You can either do it globally or on a layer by layer basis.

app.cache([keyGenerator], ttl)

Use this to define caching across your entire application


keyGenerator is a function that takes a tile object as it's only parameter and returns a cache key (string)

If you don't specify a key generator, app.defaultCacheKeyGenerator will be used, which returns a key derived from your database connection, tile layer, and tile x, y, and z.


TTL stands for time-to-live. It's how long tiles will remain in your cache, and it's defined in milliseconds. For most applications, anywhere between one day (86400000) to one week (604800000) should be fine.


In this example, we have tile.user_id available to us and we don't want to show one user tiles belonging to another user. By starting with app.defaultCacheKeyGenerator(tile) we get a cache key based on things we already want to cache by (like x, y, and z) and we can then add user_id to prevent people from seeing cached tiles unless their user_id matches.

  return app.defaultCacheKeyGenerator(tile) + ':' + tile.user_id; //cache by tile.user_id as well
}, 1000 * 60 * 60 * 24 * 30); //ttl 30 days

this.cache([keyGenerator], ttl)

Layer-specific caching works identically to global caching as defined above, except that it only applies to one layer and you define it within that layer.

In this example, slowLayer uses the same key generator as the rest of the app, but specifies a longer TTL.

app.cache(keyGenerator, 1000 * 60 * 60 * 24); //cache for one day

app.layer('slowLayer', function(tile, render){
  this.cache(1000 * 60 * 60 * 24 * 30); //cache for 30 days


In this example, only slowLayer is cached.

app.layer('fastLayer', function(tile, render){

var userMiddleware = function(req, res, tile, next){
  tile.user_id = 1;

app.layer('slowLayer', userMiddleware, function(tile, render){
    return app.defaultCacheKeyGenerator(tile) + ':' + tile.user_id;
  }, 1000 * 60 * 60 * 24); //cache for one day



Some in-browser examples of how to use the tiles generated by tilesplash:

.mvt endpoint

.topojson endpoint