Skip to content
Quilium Docs
Back to site
How-to guides

Consume an external feed

Read a remote JSON endpoint at render time with an `api` field, key it on the item the page is showing, and handle the day it stops answering — which is the part that actually needs designing.

A site often needs data it does not own: a weather forecast, exchange rates, availability from a booking system, a partner's catalogue. Quilium reads it with a single field type — api — declared in the YAML and resolved by the engine before your template runs. The template makes no network call. It reads page.custom.<field> exactly as it reads a text field.

That much is easy. The two things worth getting right are what the URL is allowed to know, and what the page looks like when the remote is down.

Live example: Cairn. Everything on this page was measured on Cairn, the demo site of a hiking-route publisher. Each route's detail page shows a three-day forecast at that route's own trailhead, pulled from Open-Meteo — a free, keyless, public endpoint.

1. Key the URL on the item the page is showing

This is the part that makes an api field useful rather than decorative, so it comes first.

Cairn's detail page-type already carries a query field named itinerary, which resolves the route from the URL slug. The api field sits next to it and reads that query's result straight out of the URL:

itineraireDetail:
  name: Itinerary (detail)
  template: itineraireDetail.liquid
  tabsets:
    config:
      name: Data
      fields:
        itinerary:
          type: query
          label: Current itinerary
          config:
            itemSet: itineraires
            limit: 1
            where:
              '&=':
                slug: '{{route.itinerary.slug}}'
        weather:
          type: api
          label: Weather at the start point (Open-Meteo)
          config:
            format: json
            timeout: 5
            caching:
              enabled: true
              validity: 60
            src: 'https://api.open-meteo.com/v1/forecast?latitude={{itinerary.coords.lat}}&longitude={{itinerary.coords.lng}}&daily=weather_code,temperature_2m_max,temperature_2m_min&timezone=Europe%2FParis&forecast_days=3'

{{itinerary.coords.lat}} is the resolved route's geo field, reached through the query field's own name, used bare as a namespace. Eight routes, eight trailheads, eight different forecasts — Les Clots at 1 429 m, Cervières at 1 968 m, Chamrousse at 2 044 m — from one line of configuration.

Three near-miss spellings, all of which fail silently. {{itinerary[0].coords.lat}} — the indexed form the template uses — does not resolve. Neither does {{page.custom.itinerary[0].coords.lat}}. Neither does {{field.itinerary.coords.lat}}: field. is for values an editor typed in another field of the same block, not for a query's result. All three yield an empty object and a page that returns 200. The working form is the bare field name: {{itinerary.coords.lat}}.

dependsOn is not the answer either — it exists on query fields, and an api field refuses it outright at validation: "A Api field can't have a 'dependsOn' property."

The variables src accepts

Variable Comes from Measured
{{<queryFieldName>.<path>}} another query field on the same type ✓ per-item forecast
{{route.*}} route parameters ✓
{{query.*}} the URL query string ✓ ?lat=48.85 reached the remote as 48.85
{{field.<name>}} a value an editor filled in another field of the same block documented; use it for a block the editor configures
{{now.*}}, {{current.locale}} the clock, the request documented
config.defaults fallback for anything the request did not supply ✓ resolved to the default when ?lat= was absent

The rule to remember: src is resolved in the same variable context as a query field's where. If you can filter a collection on it, you can put it in a URL.

2. Read it in the template

The field arrives as the parsed JSON object. No filter, no decoding step.

{% if weather.daily %}
  <dl>
    {% for jour in weather.daily.time %}
      {% assign i = forloop.index0 %}
      <div>
        <dt>{{ jour | date: 'ddd D MMM' }}</dt>
        <dd>
          {{ weather.daily.temperature_2m_max[i] | round }}°
          / {{ weather.daily.temperature_2m_min[i] | round }}°
          {% include '_wmoLabel', code: weather.daily.weather_code[i] %}
        </dd>
      </div>
    {% endfor %}
  </dl>
{% else %}
  <p>{{ 'Bulletin momentanément indisponible.' | t }}</p>
{% endif %}

Two habits this snippet is built on.

You do not control the remote's shape, so read it defensively. Open-Meteo returns parallel arrays — daily.time, daily.temperature_2m_max, daily.weather_code — not one object per day. So you loop the dates and index the others at the same rank. Indexing with a variable works (array[i]); nothing special is needed.

Anything the remote controls is untrusted input. Liquid never auto-escapes. A forecast is numbers, but a partner catalogue is somebody else's free text, and it lands in your page unfiltered. | escape it — the same discipline as a form field, for the same reason.

The date above is formatted with | date: 'ddd D MMM' and comes out jeu. 20 août on the French page, Thu 20 Aug on the English one. The filter follows the rendered locale on its own — see Run a multilingual site.

3. A dead feed is silent, and that is the whole problem

Point src at something unreachable and render the page. Measured on Cairn:

  • the page returns HTTP 200;
  • the field is an empty object — not null, not an error, not a log line you will notice;
  • nothing anywhere says the fetch failed.

Which means: without the {% else %} branch, a broken feed removes your block and tells no one. The section heading stays, the data vanishes, and the page keeps passing every check you have. Nobody files a bug about a page that returns 200.

Guard on the data you are about to loop, not on the field. {% if weather %} passes for an empty object and even for an empty string — in Liquid, "" is truthy. Test the branch you actually need: {% if weather.daily %}.

The fallback text is worth writing properly. "Temporarily unavailable — check with the national weather service before you set out" is a useful sentence on a mountain site. A blank space is not.

Note that this failure mode and a mistyped variable produce exactly the same symptom: an empty object, in 200. When a feed you believe in comes back empty, suspect the spelling in src before you suspect the remote.

4. The timeout is a page-speed budget, not a safety net

Same measurement, with a stopwatch. timeout: 5, endpoint unreachable:

code=200  time=5.24s

The visitor waited the full timeout for a page whose remote data never arrived. That is the number to design against, because it is paid by every request that misses the cache — and it is paid on the first request after the cache expires, by whichever visitor happens to be first.

So:

  • Keep timeout small. 3–5 s. The maximum is 60, and 60 seconds of a held connection is not a page, it is an outage.
  • Cache generously. validity is in minutes, default 20. A forecast at 60 is honest; exchange rates at 5 are not worth the latency they cost. Ask what the data's real refresh rate is, then round up.
  • Watch the multiplier. Keying the feed per item is powerful, and it means one cache entry per item. Eight routes is nothing. Eight hundred products, each with its own remote price, is a different conversation — and the answer there is usually a scheduled import into a collection, not a render-time fetch.

5. Where the config/content line falls

src is configuration: one URL per type, identical for every page or block of that type, invisible and uneditable in the back-office.

That is not a limitation once you see where the flexibility actually lives. What varies is what the URL reads: a collection field ({{itinerary.coords.lat}}), a field the editor filled ({{field.city}}), a URL parameter ({{query.lat}}). The editor never touches the endpoint — they fill a geo field on a route, and the right forecast follows.

An API key in src is a key in your template repository. The YAML is site settings, not a secret store, and the URL travels to anyone who can read the site's configuration. For a keyed feed, prefer config.headers (Authorization: 'Bearer …') or config.auth — same exposure, but at least the credential is not sitting in a URL that gets pasted into tickets and screenshots.

6. Writing back, and picking a neighbour

api only reads. The rest of the family:

Need Field
Read a remote JSON document at render api
Send a form payload to an external webhook http — the response is not exposed to templates
Offer a dropdown fed by a remote list smartselect with source: api — hits the remote on every CMS edit
Read your own collections query — never an api field pointed at your own site

That last row matters more than it looks. A collection on your own site is a query: in-process, no timeout, no cache to reason about, no 200-with-empty-object failure mode. Reaching your own data over HTTP buys you every problem on this page and nothing else.

7. Serving a feed, rather than consuming one

The mirror image — publishing your own JSON for someone else to consume — is a page-type with a content-type header and a template with no layout. It is written up in Build an agenda, in the section on serving the agenda as a feed.


Last updated September 6, 2026.