Open-source projects for mapping have gotten really, really good. Recently, some of the folks on Cloudflare's community Discord recommended using Protomaps for hosting and displaying maps within web apps. While I'm no expert in the web maps ecosystem, I have used Mapbox in the past and this felt like a great opportunity to try out the open-source options (especially since it's a cool use of Cloudflare's services (I work for Cloudflare)).

Turns out, it's relatively simple to do, here's what it looks like:

(This map is a low resolution map of the entire globe. You can decide to host a higher resolution map by increasing the maxzoom of the basemap you will be hosting.)

If you're looking to jump right into the code, check out the repository that accompanies this blog: https://github.com/thomasgauvin/protomaps-on-cloudflare.

A bit of context

Before jumping into the implementation, it's helpful to understand a few basic concepts about displaying maps on websites. This is what I learned over the course of implementing this project, and it'll help to understand the various pieces needed.

There are 3 layers/components involved with adding a map to your website:

  1. A library on the browser responsible for fetching the map data and rendering it on the website.

  2. The map data itself, often referred to as a basemap.

    • Map data has various formats, such as PMTiles, XYZ Tiles, MBTiles, Vector Tiles, etc. These determine the available server options.

    • OpenStreetMap provides free access to their map data, from which basemaps can be created in the various file formats.

  3. A map data (or map tile) server, which is responsible for responding to requests for the requested map tile data (The map tile data is usually very large and complex, such that it needs to be handled server-side).

    • OpenStreetMap hosts a map tile server that is easily accessed and widely used to demo mapping libraries, but it has some usage limitations and is not intended to be used as a replacement for a production map tile server. In this tutorial, we'll be self-hosting this map tile server.

For my project, I'm choosing 1) maplibre-gl-js and 2) PMTiles (Protomaps Tiles). I'm using PMTiles since they are cloud-optimized, easily stored on an object storage service, simple to serve from serverless services, and cacheable for optimized performance, all of which will land perfectly on Cloudflare. The Protomaps docs https://docs.protomaps.com/ are excellent, and they provide a Cloudflare Worker project to serve our PMTiles data which is very convenient.

Let's get started!

1) Download the PMTiles map data

Following the Protomaps docs https://docs.protomaps.com/basemaps/downloads, we'll download a portion of the map data https://maps.protomaps.com/builds/ since downloading the entire globe data would be unnecessarily large. We'll use the PMTiles CLI to extract a limited zoom portion https://docs.protomaps.com/pmtiles/cli#extract:

pmtiles extract https://build.protomaps.com/20230918.pmtiles world.pmtiles --maxzoom=6

We can then go right ahead and create a new R2 bucket and upload the file. (You can also do this via the Cloudflare dashboard, but it fits better in the tutorial as a code snippet). Assuming you are in the directory you downloaded the `world.pmtiles` file, run the following:

wrangler r2 bucket create protomaps
wrangler r2 object put protomaps/world.pmtiles --file=world.pmtiles

Great! We now have PMTiles uploaded to Cloudflare R2 and ready to be served.

2) Create the Cloudflare Workers project to serve the map tile data

Protomaps provides the code for a Cloudflare Worker project https://docs.protomaps.com/deploy/cloudflare that serves x/y/z vector tile requests by using the base PMTiles map data (obtained from our file in R2). They also provide the pre-built & minified JavaScript code for a Cloudflare Worker, but I'll be using the source code since it's easier to debug and step through.

We can get that source code by cloning the repository and stepping into the directory.

git clone https://github.com/protomaps/PMTiles.git
cd serverless/cloudflare
npm install

We also need to change the Workers project's configurations by renaming the wrangler.toml.example to wrangler.toml, and setting the R2 bucket name (for both preview and production, I'm using protomaps as created above since this is a simple demo, you'd likely want separate buckets for production vs preview workloads). We also set the ALLOWED_ORIGINS environment variables to be "*" for the time being (this could be restricted at in later step when we're done developing and we can add our URL for our deployed web application).

(For the code that accompanies this blog post, I've adapted the provided code to simplify imports and make it a standalone Workers project. You should be able to take only the cloudflare-workers-protomaps-server folder and deploy it directly.)

We're now ready to start the Cloudflare Worker, and we'll make sure to pass --remote to use the remote R2 instance with the :

wrangler dev --remote

Excellent, now we can verify that it's working properly working by accessing http://localhost:8787/world/0/0/0.mvt, which should download a .mvt file which represents the map data.

We can now deploy the Worker using the following command:

wrangler deploy

Note down the URL you've received for your Workers, since this is what we'll be using in the next step to retrieve the map information.

3) Create a React application to fetch and display the map

We can quickly scaffold a React application following Vite's create experience. I'll also install the required dependencies at the same time.

npm create vite protomaps-map-app --template react
npm install maplibre-gl react-map-gl protomaps-themes-base
npm run dev

We can then create a new file within the React project for the map component react-protomaps-client/src/components/map.tsx. (See the Protomaps docs for reference here https://docs.protomaps.com/basemaps/maplibre).

import React, { useEffect } from "react";
import Map from "react-map-gl/maplibre";
import layers from "protomaps-themes-base";
export const MapComponent = () => {
  return (
    <>
      <Map
        initialViewState={ {
          longitude: -74.006,
          latitude: 40.7128,
          zoom: 12,
        } }
        mapStyle={ {
          version: 8,
          sources: {
            protomaps: {
              type: "vector",
              tiles: [
                "https://<REPLACE WITH YOUR OWN WORKERS URL>.workers.dev/world/{z}/{x}/{y}.mvt",
              ],
              maxzoom: 6
            },
          },
          glyphs:
            "https://protomaps.github.io/basemaps-assets/fonts/{fontstack}/{range}.pbf",
          layers: layers("protomaps", "light"),
        } }
        attributionControl={false}
      />
    </>
  );
};

We can then import the component into App.jsx:

import { MapComponent } from "./components/map";

function App() {
  return (
    <div style={ { height: '100vh', width: '100vw' } }>
      <MapComponent />
    </div>
  )
}

export default App

And finally, we can deploy to Cloudflare Pages:

npm run build
wrangler pages deploy dist

Now, we can navigate to our deployed web application and see that our map is now being rendered and served by our Worker! We can verify this by going to our browser and dragging the map around. Notice that the network tab of the developer tools show requests as you drag around, indicating that maplibre is requesting additional map data for that area and the Cloudflare Worker is responding appropriately.

(See it in full screen here: https://protomaps-on-cloudflare.pages.dev/)

And that's it!

We now have a R2 bucket with our PMTiles file containing our map data, a Cloudflare Worker that responds to vector tile requests using the PMTiles file from the R2 bucket, and a React application that uses our Cloudflare Workers as the source for it's map data.

Now go ahead and try it out for yourself! Find an excuse to add a map to your web app, Protomaps is too cool not to try out! (And if you want to deploy right away, here's my project code: https://github.com/thomasgauvin/protomaps-on-cloudflare)