Self-host Maps with Protomaps and Supabase Storage

2024-06-19

8 minute read

Protomaps is an open source map of the world, deployable as a single static file on Supabase Storage.

In this tutorial, you will learn to

  • Use Protomaps to excract an area into a static PMTiles file.
  • Upload the PMTiles file to Supabase Storage.
  • Use MapLibre to render the Map onto a Web Page.
  • Use Supabase Edge Functions to restrict File Access.

Extract an area into a static PMTiles file

Protomaps provides a pmtiles CLI that can be used to cut out certain areas from the world map and compress those into a single static file.

For example, we can extract a small area around Utrecht in the Netherlands like this:


_10
pmtiles extract https://build.protomaps.com/20240618.pmtiles my_area.pmtiles --bbox=5.068050,52.112086,5.158424,52.064140

Note: make sure to update the date to the latest daily build!

This will create a my_area.pmtiles file which you can upload to Supabase Storage.

Upload the PMTiles file to Supabase Storage

In your Supabase Dashboard navigate to Storage and click "New Bucket" and create a new public bucket called public-maps.

Upload the my_area.pmtiles file created earlier to your public bucket. Once uploaded, click the file and tap "Get URL".

Supabase Storage supports the required HTTP Range Requests out of the box, allowing you to use the public storage URL directly from your maps client.

Use MapLibre to render the Map

PMTiles easily works with both MapLibre GL and Leaflet. In our example we wil use MapLibre GL, which is a TypeScript library that uses WebGL to render interactive maps from vector tiles in a browser.

This is a vanilla JS example which uses CDN releases of the libraries. You can very easily adapt it to work with React as well, for example using the react-map-gl library.

index.html

_54
<html>
_54
<head>
_54
<title>Overture Places</title>
_54
<meta charset="utf-8" />
_54
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
_54
<link
_54
rel="stylesheet"
_54
href="https://unpkg.com/maplibre-gl@4.1.2/dist/maplibre-gl.css"
_54
crossorigin="anonymous"
_54
/>
_54
<script
_54
src="https://unpkg.com/maplibre-gl@4.1.2/dist/maplibre-gl.js"
_54
crossorigin="anonymous"
_54
></script>
_54
<script src="https://unpkg.com/protomaps-themes-base@2.0.0-alpha.5/dist/index.js"></script>
_54
<script src="https://unpkg.com/pmtiles@3.0.6/dist/pmtiles.js"></script>
_54
<style>
_54
body {
_54
margin: 0;
_54
}
_54
#map {
_54
height: 100%;
_54
width: 100%;
_54
}
_54
</style>
_54
</head>
_54
<body>
_54
<div id="map"></div>
_54
<script type="text/javascript">
_54
// Add the PMTiles Protocol:
_54
let protocol = new pmtiles.Protocol()
_54
maplibregl.addProtocol('pmtiles', protocol.tile)
_54
_54
// Load the Map tiles directly from Supabase Storage:
_54
const map = new maplibregl.Map({
_54
hash: true,
_54
container: 'map',
_54
style: {
_54
version: 8,
_54
glyphs: 'https://cdn.protomaps.com/fonts/pbf/{fontstack}/{range}.pbf',
_54
sources: {
_54
protomaps: {
_54
attribution:
_54
'<a href="https://github.com/protomaps/basemaps">Protomaps</a> © <a href="https://openstreetmap.org">OpenStreetMap</a>',
_54
type: 'vector',
_54
url: 'pmtiles://https://<your-project-ref>.supabase.co/storage/v1/object/public/public-maps/my_area.pmtiles',
_54
},
_54
},
_54
layers: protomaps_themes_base.default('protomaps', 'dark'),
_54
},
_54
})
_54
</script>
_54
</body>
_54
</html>

Use Supabase Edge Functions to restrict Access

A public Supabase Storage bucket allows access from any origin, which might not be ideal for your use case. At the time of writing, you're not able to modify the CORS settings for Supabase Storage buckets, however you can utilize Supabase Edge Functions to restrict access to your PMTiles files, allowing you to even pair it with Supabase Auth to restrict access to certain users for example.

In your Supabase Dashboard, create a new private storage bucket called maps-private and upload your my_area.pmtiles file there. Files in private buckets can only be accessed through either a short-lived signed URL, or by passing the secret service role key as an authorization header. Since our Edge Function is a secure server-side environment, we can utilize the latter approach here.

Using the Supabase CLI, create a new Edge Function by running supabase functions new maps-private, then add the following code to your newly created function:

supabase/functions/maps-private/index.ts

_31
const ALLOWED_ORIGINS = ['http://localhost:8000']
_31
const corsHeaders = {
_31
'Access-Control-Allow-Origin': ALLOWED_ORIGINS.join(','),
_31
'Access-Control-Allow-Headers':
_31
'authorization, x-client-info, apikey, content-type, range, if-match',
_31
'Access-Control-Expose-Headers': 'range, accept-ranges, etag',
_31
'Access-Control-Max-Age': '300',
_31
}
_31
_31
Deno.serve((req) => {
_31
// This is needed if you're planning to invoke your function from a browser.
_31
if (req.method === 'OPTIONS') {
_31
return new Response('ok', { headers: corsHeaders })
_31
}
_31
_31
// Check origin
_31
const origin = req.headers.get('Origin')
_31
_31
if (!origin || !ALLOWED_ORIGINS.includes(origin)) {
_31
return new Response('Not Allowed', { status: 405 })
_31
}
_31
_31
const reqUrl = new URL(req.url)
_31
const url = `${Deno.env.get('SUPABASE_URL')}/storage/v1/object/authenticated${reqUrl.pathname}`
_31
_31
const { method, headers } = req
_31
// Add Auth header
_31
const modHeaders = new Headers(headers)
_31
modHeaders.append('authorization', `Bearer ${Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!}`)
_31
return fetch(url, { method, headers: modHeaders })
_31
})

If you want to further restrict access based on authenticated users, you can pair your Edge Function with Supabase Auth as shown in this example.

Lastly, we need to deploy our Edge Function to Supabase by running supabase functions deploy maps-private --no-verify-jwt. Note that the --no-verify-jwt flag is required if you want to allow public access from your website without any Supabase Auth User.

Now we can simply replace the public storage URL with our Edge Functions URL to proxy the range requests to our private bucket:

index.html

_19
// ...
_19
const map = new maplibregl.Map({
_19
hash: true,
_19
container: 'map',
_19
style: {
_19
version: 8,
_19
glyphs: 'https://cdn.protomaps.com/fonts/pbf/{fontstack}/{range}.pbf',
_19
sources: {
_19
protomaps: {
_19
attribution:
_19
'<a href="https://github.com/protomaps/basemaps">Protomaps</a> © <a href="https://openstreetmap.org">OpenStreetMap</a>',
_19
type: 'vector',
_19
url: 'pmtiles://https://<project_ref>.supabase.co/functions/v1/maps-private/my_area.pmtiles',
_19
},
_19
},
_19
layers: protomaps_themes_base.default('protomaps', 'dark'),
_19
},
_19
})
_19
// ...

Now go ahead and serve your index.html file, for example via Python SimpleHTTPServer: python3 -m http.server and admire your beautiful map on localhost:8000!

Conclusion

Protomaps is a fantastic open source project that allows you to host your own Google Maps alternative on Supabase Storage. You can further extend this with powerful PostGIS capabilities to programmatically generate Vector Tiles which we will explore in the next post in this series. So make sure you subscribe to our Twitter and YouTube channels to not miss out! See you then!

More Supabase

Share this article

Build in a weekend, scale to millions