# Quickstart: a map in ten minutes

> Go from an empty project to published entries on a MapLibre GL map, a Leaflet map and a vector-tile layer, with copy-ready curl and JavaScript.

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:

```bash
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](https://spatibase.com/docs/reference/authentication/)).

## Step 2: Publish one entry

The Content Delivery API only returns published entries, so you need at least one. In the web app:

1. 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**.
2. 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.
3. 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](https://spatibase.com/docs/get-started/first-map/).

## Step 3: Fetch entries and GeoJSON with curl

`/entries` returns published entries as JSON, 50 per page by default:

```bash
curl -s "$API/entries" | jq -c '.data[0], .meta'
```

```json
{"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:

```bash
curl -s "$API/entries.geojson" | jq -c '.features[0]'
```

```json
{"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](https://spatibase.com/docs/reference/delivery-api/#list-entries).

## 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.

```html
<!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](https://spatibase.com/docs/reference/geojson-and-tiles/#maplibre-gl-js-and-mapbox-gl-js) 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.

```html
<!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](https://openfreemap.org) 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 the `style` option.

## 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:

```js
  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:

```bash
curl -s "$API/tiles.json" | jq -c '{tiles, bounds, fields: .vector_layers[0].fields}'
```

```json
{"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](https://spatibase.com/docs/build/recipes/)
- Every endpoint and parameter: [Content Delivery API reference](https://spatibase.com/docs/reference/delivery-api/)
- A typed client for your own fields, Postman or Bruno: the [OpenAPI files](https://spatibase.com/docs/reference/delivery-api/#machine-readable-spec) of the Content Delivery API
- Tiles in QGIS, ArcGIS and Mapbox GL: [GeoJSON and vector tiles](https://spatibase.com/docs/reference/geojson-and-tiles/)
- Starting from an app that already has its places in a file, with a coding agent: [Start from your code](https://spatibase.com/docs/build/start-from-your-code/)
- Bringing in a spreadsheet or GeoJSON file instead of typing: [Import your data](https://spatibase.com/docs/content/import/)
- Private projects and API keys: [Authentication](https://spatibase.com/docs/reference/authentication/)
