Quickstart: a map in ten minutes
This quickstart takes you from an empty project to published entries on a MapLibre GL map, a Leaflet map and a vector-tile layer in about ten minutes. It is for developers; every code block is complete and ready to copy.
You need a Spatibase account, a browser, and curl and jq for the command-line steps.
Step 1: Get your project id
Every Content Delivery API URL contains the project’s id, a UUID. Open your project in the web app at https://app.spatibase.com and copy the id from the address bar: it is the part after /projects/, for example 61604e01-b862-437b-8151-9cb9353a4f57 in https://app.spatibase.com/projects/61604e01-b862-437b-8151-9cb9353a4f57/entries.
The project’s API page shows the same id inside ready-made URLs, with a Copy API address button. Keep it in a shell variable for the next steps:
export PROJECT_ID=61604e01-b862-437b-8151-9cb9353a4f57 # your project's id
export API=https://api.spatibase.com/delivery/v1/projects/$PROJECT_ID
Note: This quickstart assumes the project’s Content Delivery API is public, which is the default. If the project is private, create a key under API, then Access & keys, and add
-H "X-API-Key: $API_KEY"to every curl command (Authentication).
Step 2: Publish one entry
The Content Delivery API only returns published entries, so you need at least one. In the web app:
- Open Content model and click Create your first type. Name it “Cafe”, under Allowed shapes keep only Point ticked, and under the assigned elements click Assign element, then Create new element… to add a “Description” (Rich text) and a “Rating” (Number). Click Create type.
- Open Entries, click New above the list and choose “Cafe”. Enter the title “Bean There”, click Place a point and click the map. Fill in a description and a rating. Your changes save as a draft by themselves.
- Click Ready to publish, then click Publish in the side panel and confirm.
Any type and entry work; the rest of this page uses the apiKeys cafe, description and rating that Spatibase suggests for these names. A walkthrough in the app, with no code, is in Make your first map.
Step 3: Fetch entries and GeoJSON with curl
/entries returns published entries as JSON, 50 per page by default:
curl -s "$API/entries" | jq -c '.data[0], .meta'
{"id":"d47e1a25-6c0f-4f7e-9a51-2b8c4e1f0a37","type":"cafe","typePath":["cafe"],"title":"Bean There","geometry":{"type":"Point","coordinates":[14.4208,50.0875]},"fields":{"description":"Small **coffee** bar.","rating":4.7},"groups":[],"createdAt":"2026-10-15T08:02:11.120Z","updatedAt":"2026-10-15T08:04:52.873Z","publishedAt":"2026-10-15T08:04:52.873Z"}
{"page":1,"limit":50,"total":1,"totalPages":1}
/entries.geojson returns the same entries as a GeoJSON FeatureCollection, ready for any map library. Feature properties are id, type, title and every field:
curl -s "$API/entries.geojson" | jq -c '.features[0]'
{"type":"Feature","id":"d47e1a25-6c0f-4f7e-9a51-2b8c4e1f0a37","geometry":{"type":"Point","coordinates":[14.4208,50.0875]},"properties":{"id":"d47e1a25-6c0f-4f7e-9a51-2b8c4e1f0a37","type":"cafe","title":"Bean There","description":"Small **coffee** bar.","rating":4.7}}
Both responses allow any origin (CORS *) and carry an ETag, so a browser page can call them directly. After a publish, allow up to a minute for cached responses to refresh. Filters, paging and sorting are in the Content Delivery API reference.
Step 4: Show entries on a MapLibre GL map
Save this as maplibre.html, replace YOUR_PROJECT_ID, and open the file in a browser. A double-click is enough; no web server is needed.
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Spatibase + MapLibre GL</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.css" />
<script src="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.js"></script>
<style>html, body, #map { height: 100%; margin: 0 }</style>
</head>
<body>
<div id="map"></div>
<script>
const PROJECT_ID = 'YOUR_PROJECT_ID'
const API = `https://api.spatibase.com/delivery/v1/projects/${PROJECT_ID}`
const map = new maplibregl.Map({
container: 'map',
style: 'https://tiles.openfreemap.org/styles/liberty', // OpenFreeMap: free, no key, credits shown by MapLibre
center: [0, 20],
zoom: 1
})
map.on('load', async () => {
const geojson = await (await fetch(`${API}/entries.geojson`)).json()
map.addSource('entries', { type: 'geojson', data: geojson })
map.addLayer({
id: 'entries',
type: 'circle',
source: 'entries',
filter: ['==', ['geometry-type'], 'Point'],
paint: { 'circle-radius': 7, 'circle-color': '#e11d48', 'circle-stroke-width': 2, 'circle-stroke-color': '#fff' }
})
// Zoom to everything we got
const bounds = new maplibregl.LngLatBounds()
const extend = c => (typeof c[0] === 'number' ? bounds.extend(c) : c.forEach(extend))
geojson.features.forEach(f => extend(f.geometry.coordinates))
if (!bounds.isEmpty()) map.fitBounds(bounds, { padding: 60, maxZoom: 15, duration: 0 })
map.on('click', 'entries', e => {
new maplibregl.Popup().setLngLat(e.lngLat).setText(e.features[0].properties.title).addTo(map)
})
})
</script>
</body>
</html>
Lines and areas need a line or fill layer with a matching geometry-type filter; the vector tile example in step 6 and GeoJSON and vector tiles show both.
Step 5: Show entries on a Leaflet map
Save this as leaflet.html, replace YOUR_PROJECT_ID, and open it in a browser. It draws the same OpenFreeMap basemap with MapLibre inside Leaflet, which is why it loads three scripts.
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Spatibase + Leaflet</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<link rel="stylesheet" href="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.css" />
<script src="https://unpkg.com/maplibre-gl@4.7.1/dist/maplibre-gl.js"></script>
<script src="https://unpkg.com/@maplibre/maplibre-gl-leaflet@0.1.4/leaflet-maplibre-gl.js"></script>
<style>html, body, #map { height: 100%; margin: 0 }</style>
</head>
<body>
<div id="map"></div>
<script>
const PROJECT_ID = 'YOUR_PROJECT_ID'
const API = `https://api.spatibase.com/delivery/v1/projects/${PROJECT_ID}`
const map = L.map('map', { maxZoom: 19 }).setView([20, 0], 2)
// OpenFreeMap vector basemap, drawn by MapLibre inside Leaflet (free, no key; the plugin shows its credits)
L.maplibreGL({ style: 'https://tiles.openfreemap.org/styles/liberty' }).addTo(map)
fetch(`${API}/entries.geojson`)
.then(res => res.json())
.then(geojson => {
const layer = L.geoJSON(geojson, {
pointToLayer: (feature, latlng) =>
L.circleMarker(latlng, { radius: 7, color: '#fff', weight: 2, fillColor: '#e11d48', fillOpacity: 1 }),
onEachFeature: (feature, layer) => {
const el = document.createElement('strong')
el.textContent = feature.properties.title // textContent: titles are editor input
layer.bindPopup(el)
}
}).addTo(map)
if (layer.getBounds().isValid()) map.fitBounds(layer.getBounds(), { padding: [30, 30], maxZoom: 14 })
})
</script>
</body>
</html>
Tip: OpenFreeMap is free and needs no key; keep its credits visible. Do not point production apps at
tile.openstreetmap.org: OpenStreetMap’s public tile servers are not meant for that and block heavy use. Any other MapLibre style URL works the same way in thestyleoption.
Step 6: Switch to vector tiles
Vector tiles load only what is on screen and carry each type’s color, so they suit large projects better than GeoJSON. entries.geojson returns up to 1000 entries per request by default and 5000 at most.
Keep the page from step 4 (same <head>, PROJECT_ID, API and map) and replace the whole map.on('load', ...) block with this:
map.on('load', () => {
// TileJSON tells MapLibre the tile URL, the bounds and the field names
map.addSource('entries', { type: 'vector', url: `${API}/tiles.json` })
map.addLayer({
id: 'entries',
type: 'circle',
source: 'entries',
'source-layer': 'entries', // the one layer in the tileset
filter: ['==', ['geometry-type'], 'Point'],
paint: {
'circle-radius': 7,
'circle-color': ['get', 'color'], // every feature carries its type's color
'circle-stroke-width': 2,
'circle-stroke-color': '#fff'
}
})
map.on('click', 'entries', e => {
new maplibregl.Popup().setLngLat(e.lngLat).setText(e.features[0].properties.title).addTo(map)
})
// Zoom to the published content
map.on('sourcedata', function once(e) {
if (e.sourceId !== 'entries' || !map.getSource('entries').bounds) return
map.off('sourcedata', once)
const [w, s, e2, n] = map.getSource('entries').bounds
map.fitBounds([[w, s], [e2, n]], { padding: 60, maxZoom: 15, duration: 0 })
})
})
tiles.json is a standard TileJSON 3.0 document. This is what MapLibre reads from it:
curl -s "$API/tiles.json" | jq -c '{tiles, bounds, fields: .vector_layers[0].fields}'
{"tiles":["https://api.spatibase.com/delivery/v1/projects/61604e01-b862-437b-8151-9cb9353a4f57/tiles/{z}/{x}/{y}.pbf"],"bounds":[14.4208,50.0875,14.4208,50.0875],"fields":{"id":"String","type":"String","title":"String","color":"String","rating":"Number"}}
The tileset has one layer, entries. Each feature carries id, type, title, color and scalar fields such as rating; rich values such as images and descriptions stay out of tiles, so load /entries/<id> when someone clicks a feature.
Where to go next
- Store locators, filters, groups and caching: Recipes
- Every endpoint and parameter: Content Delivery API reference
- A typed client for your own fields, Postman or Bruno: the OpenAPI files of the Content Delivery API
- Tiles in QGIS, ArcGIS and Mapbox GL: GeoJSON and vector tiles
- Starting from an app that already has its places in a file, with a coding agent: Start from your code
- Bringing in a spreadsheet or GeoJSON file instead of typing: Import your data
- Private projects and API keys: Authentication
Updated