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.
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.
curl -sg "$API/entries?type=store-location&near=-124.0,45.0&radius=150000&sort=distance" \
| jq -c '.data[] | {title, distance}'
{"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:
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.
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:
// 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):
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:
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.
# 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}]'
[{"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}]
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]'
["Point Reyes National Seashore","Olympic National Park","Redwood National Park"]
["Cape Perpetua Scenic Area"]
Bad input gets a 400 whose message names the fix:
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:
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:
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:
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}'
{"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):
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'
{"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:
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]'
["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.
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"
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:
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.
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.
// 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, theAccept-Languageheader of the visitor’s browser picks the best language of the project, andContent-Languagesays which one you got. - Switch without a request.
locale=*returns the default language plus every stored translation intranslations, so one response serves all languages:e.translations.de?.title ?? e.title. - Mark what is missing. When
missingTranslationsisn’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; sendIf-None-Matchas usual. Always putlocalein the URL if you serve it through a CDN. - Map tiles stay in the default language. For translated map labels, load
entries.geojson?locale=deinstead.
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.
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}'
{"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.
Related
Updated