# Recipes

> Copy ready-made Delivery API calls for a store locator, a delivery area check, field filters, colors per type, ordered groups and ETag caching.

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](https://spatibase.com/docs/reference/delivery-api/).

## 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](https://spatibase.com/docs/reference/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](https://spatibase.com/docs/build/recipes/#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](https://spatibase.com/docs/reference/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](https://spatibase.com/docs/reference/geojson-and-tiles/#filter-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](https://spatibase.com/docs/content/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](https://spatibase.com/docs/reference/assets-and-images/).

## Serve your content in several languages

In a project with [languages](https://spatibase.com/docs/content/translations/), 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](https://spatibase.com/docs/reference/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.

## Related

- [Content Delivery API reference](https://spatibase.com/docs/reference/delivery-api/)
- [Field values](https://spatibase.com/docs/reference/field-values/)
- [Translate your content](https://spatibase.com/docs/content/translations/)
- [GeoJSON and vector tiles](https://spatibase.com/docs/reference/geojson-and-tiles/)
- [Quickstart](https://spatibase.com/docs/build/quickstart/)
