Recipes

These recipes solve common tasks with the Spatibase Content Delivery API: a store locator, a delivery area check, field filters, a color per type, ordered lists, references, caching, responsive images, several languages and subtypes. Each one stands alone; the full parameter list is in the Content Delivery API reference.

How to run the examples

Set two shell variables, then copy any command. $PROJECT_ID is the UUID in your project’s address in the web app.

bash
export PROJECT_ID=61604e01-b862-437b-8151-9cb9353a4f57   # your project's id
export API=https://api.spatibase.com/delivery/v1/projects/$PROJECT_ID

The outputs come from a sample project with parks, regions, stores and trip ideas along the Pacific coast; your type and element apiKeys will differ. Use curl -g when a URL contains [ and ], so curl does not treat them as patterns. For a private project, add -H "X-API-Key: $API_KEY" to each command (Authentication).

Store locator: nearest first

To list places nearest to a point, combine near, radius and sort=distance. near=lng,lat limits results to a radius in meters (default 10000, so always set radius), sort=distance orders them, and every entry then carries distance in meters. Only the main location is used.

bash
curl -sg "$API/entries?type=store-location&near=-124.0,45.0&radius=150000&sort=distance" \
  | jq -c '.data[] | {title, distance}'
json
{"title":"Pacific Coast Outdoors – Newport","distance":41669.9}
{"title":"Pacific Coast Outdoors – Cannon Beach","distance":99160.8}

In the browser, pass the visitor’s position:

js
const API = 'https://api.spatibase.com/delivery/v1/projects/YOUR_PROJECT_ID'
const [lng, lat] = [-124.0, 45.0]            // for example from navigator.geolocation
const params = new URLSearchParams({
  type: 'store-location', near: `${lng},${lat}`, radius: '150000', sort: 'distance', limit: '10'
})
const { data } = await (await fetch(`${API}/entries?${params}`)).json()
for (const s of data) console.log(`${(s.distance / 1000).toFixed(1)} km  ${s.title}`)

sort=distance without near, or radius without near, is a 400. The radius can be at most 20037509 meters (half the Earth’s circumference).

When the closest store may be far away and you always want an answer, use nearest instead: it returns every store closest first, with no radius, and limit keeps the list short. nearest together with near or radius is a 400.

bash
curl -sg "$API/entries?type=store-location&nearest=-124.0,45.0&limit=3" | jq -c '.data[] | {title, distance}'

Every user position is a different URL, so near responses are rarely served from a cache (see Cache with ETag and 304). For a small map that shows all its places anyway, fetch the list once without near (one cacheable URL) and compute the distance in the browser instead; use near when the server should filter or order, e.g. “the 10 nearest” out of many entries:

js
// Haversine distance in meters between two [lng, lat] points
const distance = ([lng1, lat1], [lng2, lat2]) => {
  const r = Math.PI / 180, a = Math.sin((lat2 - lat1) * r / 2) ** 2 +
    Math.cos(lat1 * r) * Math.cos(lat2 * r) * Math.sin((lng2 - lng1) * r / 2) ** 2
  return 12742000 * Math.asin(Math.sqrt(a))
}
const km = distance([lng, lat], entry.geometry.coordinates) / 1000   // Point geometries

Is this address in our delivery area?

To check whether a point lies inside an area, use contains=lng,lat. It returns the entries whose main location is an area covering the point, its edge included; an empty data means no area covers it. Your app needs the address’s coordinates first, from your own geocoder or the browser’s location: Spatibase doesn’t geocode in this call.

With delivery zones drawn as entries of their own (a delivery-zone type with polygons):

bash
curl -sg "$API/entries?type=delivery-zone&contains=-123.9,41.5" | jq -c '.data[] | {title}'

When each store has its delivery area drawn in a geo field instead, add containsField with that field’s apiKey. The answer is the stores that deliver to the address:

js
const [lng, lat] = [-123.9, 41.5]            // the customer's address, already geocoded
const params = new URLSearchParams({ type: 'store-location', contains: `${lng},${lat}`, containsField: 'delivery-area' })
const { data } = await (await fetch(`${API}/entries?${params}`)).json()
console.log(data.length ? `Delivered by ${data.map(s => s.title).join(', ')}` : 'Sorry, we don\'t deliver there yet')

Points and lines never match, in the main location or in the field. contains combines with every other parameter, so &filter[open]=true keeps only the stores that are open.

Filter by a field

To return only entries whose field matches a value, use filter[<apiKey>]=<value> for equality or filter[<apiKey>][<operator>]=<value> for comparisons. The operators are eq, ne, gt, gte, lt, lte, in, contains and exists; which ones a field accepts depends on its kind. Several filters combine with AND.

bash
# number at least 4.8, and a multiple-choice field that contains "camping"
curl -sg "$API/entries?type=park&filter[rating][gte]=4.8&filter[amenities]=camping&sort=title" \
  | jq -c '[.data[] | {title, rating: .fields.rating}]'
json
[{"title":"Cape Perpetua Scenic Area","rating":4.8},{"title":"Olympic National Park","rating":4.9},{"title":"Point Reyes National Seashore","rating":4.8},{"title":"Redwood National Park","rating":4.9}]
bash
curl -sg "$API/entries?filter[entrance-fee][exists]=true" | jq -c '[.data[].title]'
curl -sg "$API/entries?filter[amenities][in]=camping,dog-friendly&type=park&subtypes=false" | jq -c '[.data[].title]'
json
["Point Reyes National Seashore","Olympic National Park","Redwood National Park"]
["Cape Perpetua Scenic Area"]

Bad input gets a 400 whose message names the fix:

text
filter[rating][gte] must be a number for number element "rating" (got "high")
Unknown filter key "nope" in filter[nope]. Filterable element apiKeys: activities, amenities, ...

You cannot sort by a field value: sort accepts only title, createdAt, updatedAt, publishedAt, distance and group. Sort in your code, or ask editors to put the entries in a group. Operators per element kind are listed in Content Delivery API: filters.

Show one type in its own color

To color entries by type, either load one GeoJSON source per type and set the color yourself, or use vector tiles, where every feature already carries its type’s color.

With GeoJSON, filter by type and pick the color in your style, because GeoJSON properties do not include the type color:

js
map.addSource('stores', { type: 'geojson', data: `${API}/entries.geojson?type=store-location` })
map.addLayer({
  id: 'stores', type: 'circle', source: 'stores',
  paint: { 'circle-radius': 8, 'circle-color': '#ea580c', 'circle-stroke-width': 2, 'circle-stroke-color': '#fff' }
})

With vector tiles, one source serves all types and every feature has type (the type apiKey) and color (the type’s Type color from the web app), so a layer per type needs only a filter:

js
map.addSource('all', { type: 'vector', url: `${API}/tiles.json` })
map.addLayer({
  id: 'national-parks', type: 'circle', source: 'all', 'source-layer': 'entries',
  filter: ['==', ['get', 'type'], 'national-park'],
  paint: { 'circle-radius': 6, 'circle-color': ['get', 'color'] }
})

A style filter on type matches that exact type only, not its subtypes. To include subtypes, list them with ['in', ['get', 'type'], ['literal', ['park', 'national-park', 'state-park']]], or request filtered tiles with ?type=park (GeoJSON and vector tiles).

Ordered list from a group

To show entries in an order editors set by hand, read a group. /groups/<groupKey> returns the group’s published entries in that order:

bash
curl -s "$API/groups" | jq -c '.[] | {apiKey, entryCount}'
curl -s "$API/groups/coastal-road-trip" | jq -c '{group: .data.name, titles: [.entries[].title], total: .meta.total}'
json
{"apiKey":"featured","entryCount":3}
{"apiKey":"coastal-road-trip","entryCount":5}
{"group":"Coastal road trip","titles":["Point Reyes National Seashore","Redwood National Park","Cape Perpetua Scenic Area","Ecola State Park","Cape Disappointment State Park"],"total":5}

To combine a group’s order with filters or bbox, use /entries?group=coastal-road-trip&sort=group; sort=group needs exactly one group. Groups list only published entries, so an unpublished member simply does not appear. Editors manage groups as described in Groups.

Resolve references

A reference field returns a stub, {id, type, title}. Add include=references to replace each stub with the full published entry, one level deep (references inside the expanded entry stay stubs):

bash
curl -sg "$API/entries?type=national-park&limit=1" | jq -c '.data[0].fields.region'
curl -sg "$API/entries?type=national-park&limit=1&include=references" | jq -c '.data[0].fields.region | keys'
json
{"id":"d2f495d4-61ce-4cad-bbde-c2e800159d88","type":"region","title":"Northern California Coast"}
["createdAt","fields","geometry","groups","id","publishedAt","title","type","typePath","updatedAt"]

The other direction, such as all stores in one region, is a filter on the target’s id:

bash
RID=$(curl -s "$API/entries?type=region&q=Northern" | jq -r '.data[0].id')
curl -sg "$API/entries?filter[region]=$RID&type=store-location&sort=title" | jq -c '[.data[].title]'
json
["Pacific Coast Outdoors – Arcata","Pacific Coast Outdoors – Mendocino"]

A reference to an entry that is unpublished, deleted or of a switched-off type is hidden: a single reference becomes null, and a multiple reference drops it.

Cache with ETag and 304

To avoid downloading unchanged content, send the last ETag back in If-None-Match; an unchanged response is then a bodiless 304 Not Modified. The ETag is per URL, query string included, and changes when the project’s content version changes, for example on a publish.

bash
E=$(curl -si "$API/entries?type=park" | grep -i '^etag' | cut -d' ' -f2 | tr -d '\r')
curl -s -o /dev/null -w '%{http_code}\n' "$API/entries?type=park" -H "If-None-Match: $E"
curl -s -o /dev/null -w '%{http_code}\n' "$API/entries?type=park&limit=5" -H "If-None-Match: $E"
text
304
200

The second request is a different URL, so it is a full 200. Browsers revalidate for you. In Node or your own client cache, do it by hand:

js
const cache = new Map()
async function getJson(url) {
  const hit = cache.get(url)
  const res = await fetch(url, { headers: hit ? { 'If-None-Match': hit.etag } : {} })
  if (res.status === 304) return hit.body
  if (!res.ok) throw new Error((await res.json()).error.message)
  const body = await res.json()
  cache.set(url, { etag: res.headers.get('etag'), body })
  return body
}

Saving a draft leaves the ETag valid; publishing changes it. Public projects send Cache-Control: public, max-age=60, s-maxage=300, stale-while-revalidate=86400, so expect a publish to show up within 60 seconds in browsers and 300 seconds on a CDN. Private projects send private, max-age=60; send the API key on the conditional request too.

Responsive images

To serve images at the right size, add w and fmt to an image’s url. images fields give url, width, height and alt; sizes snap up to a fixed ladder (400, 800 and 1280 are on it) and are never enlarged beyond the original. Resized images are served with Cache-Control: public, max-age=31536000, immutable.

js
function coverImage(image, sizes = '(min-width: 800px) 800px, 100vw') {
  const img = document.createElement('img')
  const url = w => `${image.url}?w=${w}&fmt=webp`
  img.src = url(800)                                   // fallback
  img.srcset = [400, 800, 1280].map(w => `${url(w)} ${w}w`).join(', ')
  img.sizes = sizes
  img.width = image.width                              // intrinsic size avoids layout shift
  img.height = image.height
  img.alt = image.alt
  img.loading = 'lazy'
  return img
}

For a 1600×1000 JPEG of 31 KB, the WebP variants were about 4 KB at w=400, 9 KB at w=800 and 13 KB at w=1280. All options (h, fit, q) and the full size ladder are in Assets and images.

Serve your content in several languages

In a project with languages, ask for the language with locale. A field without a translation falls back to the default language, and missingTranslations names the fields that did.

js
// 1. read the project's languages once
const project = await (await fetch(API)).json()
console.log(project.locales, project.defaultLocale) // ['en', 'de', 'cs'] 'en'

// 2. load the entries in the visitor's language
async function load(lang) {
  const res = await fetch(`${API}/entries?type=park&locale=${lang}`)
  const { data } = await res.json()
  return data.map(e => ({ title: e.title, text: e.fields.description, translated: e.missingTranslations.length === 0 }))
}
const parks = await load('de')
  • Let the browser choose. Without locale, the Accept-Language header of the visitor’s browser picks the best language of the project, and Content-Language says which one you got.
  • Switch without a request. locale=* returns the default language plus every stored translation in translations, so one response serves all languages: e.translations.de?.title ?? e.title.
  • Mark what is missing. When missingTranslations isn’t empty, show a hint such as “Not translated yet”, or hide that entry from the other language.
  • Cache per language. Every language has its own ETag; send If-None-Match as usual. Always put locale in the URL if you serve it through a CDN.
  • Map tiles stay in the default language. For translated map labels, load entries.geojson?locale=de instead.

An unknown language answers 400 locale_unknown and lists the valid ones. All details are in Content Delivery API: Languages.

Work with subtypes

Asking for a parent type also returns entries of its subtypes. Each entry reports its own type and the full typePath; add subtypes=false to get exactly the listed type.

bash
curl -sg "$API/entries?type=park&limit=100" | jq -c '[.data[] | .type] | group_by(.) | map({(.[0]): length}) | add'
curl -sg "$API/entries?type=park&subtypes=false" | jq -c '[.data[].type]'
curl -s "$API/types" | jq -c '.[] | select(.apiKey=="national-park") | {parent, path, children}'
json
{"national-park":3,"park":1,"state-park":2}
["park"]
{"parent":"park","path":["park","national-park"],"children":[]}

A subtype inherits its parent’s elements: in /types, elements[].inheritedFrom names the ancestor each element comes from, and national-park adds its own entrance-fee. Filtering on an inherited field works across the whole family, so type=park&filter[rating][gte]=4.8 covers parks, national parks and state parks.

Updated

Docs support

How can we help?

A person on our team reads every message. We include the page you are on, so you don't have to explain where.

Or write to hello@spatibase.com. For outages, check System status.

Talk to us

Not sure it’s a fit? Let’s find out

Tell us about your use case and what you need, from data model to security and deployment. In 30 minutes we’ll look at it together and tell you honestly if we can help.

Or write to hello@spatibase.com.