Read from an API

Fetch live JSON, reshape it for the page and cope when the API has a bad day.

In this tutorial you build Weather Now, a small site that finds a place by name, shows its live forecast, and answers with an honest status code when the weather service lets it down.

You need the crisptastic executable from the Quickstart and a network connection. The data comes from Open-Meteo (open-meteo.com), a free public API that needs no key. Every file below was served by the real engine against the real API while this tutorial was written, so the temperatures you see will differ and everything else should match.

1. Create the folder

mkdir weather-now && cd weather-now
mkdir _partials

Everything below is a file inside weather-now/.

2. Look at the API first

Before you write a page, see what the API sends. This one finds a place:

curl -s 'https://geocoding-api.open-meteo.com/v1/search?name=Lisbon&count=1'
{"results":[{"id":2267057,"name":"Lisbon","latitude":38.72509,"longitude":-9.1498,"elevation":68.0,"feature_code":"PPLC","country_code":"PT","admin1_id":2267056,"admin2_id":6458923,"timezone":"Europe/Lisbon","population":517802,"country_id":2264397,"country":"Portugal","admin1":"Lisbon District","admin2":"Lisbon Municipality"}],"generationtime_ms":0.14746189}

There is a results array of objects. Try a name that matches nothing and the key is missing altogether, not empty:

curl -s 'https://geocoding-api.open-meteo.com/v1/search?name=Xqzzyville&count=1'
{"generationtime_ms":0.15306473}

This one gets the forecast for a place:

curl -s 'https://api.open-meteo.com/v1/forecast?latitude=38.72&longitude=-9.15&current=temperature_2m,wind_speed_10m&daily=temperature_2m_max,temperature_2m_min&forecast_days=3&timezone=auto'
{"latitude":38.746044,"longitude":-9.175565,"generationtime_ms":0.0864267349243164,"utc_offset_seconds":0,"timezone":"Europe/Lisbon","timezone_abbreviation":"GMT","elevation":65.0,"current_units":{"time":"iso8601","interval":"seconds","temperature_2m":"°C","wind_speed_10m":"km/h"},"current":{"time":"2026-11-05T00:45","interval":900,"temperature_2m":9.7,"wind_speed_10m":4.2},"daily_units":{"time":"iso8601","temperature_2m_max":"°C","temperature_2m_min":"°C"},"daily":{"time":["2026-11-05","2026-11-06","2026-11-07"],"temperature_2m_max":[18.7,18.7,21.4],"temperature_2m_min":[8.3,10.7,15.4]}}

current is one object. daily is three arrays side by side, which you will come back to in step 7.

3. A shared layout

_partials/_layout-open.html.crisp
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>{{ title }} · Weather Now</title>
</head>
<body>
<header><a href="/">Weather Now</a></header>
<main>
_partials/_layout-close.html.crisp
</main>
</body>
</html>

Each page sets title with @let before it includes the first partial.

4. A first fetch

forecast.html.crisp
@let title = "Forecast"
@fetch weather = get_json("https://api.open-meteo.com/v1/forecast", query: { latitude: 38.72, longitude: -9.15, current: "temperature_2m,wind_speed_10m" }, timeout_ms: 3000)
@include("_partials/_layout-open.html.crisp")
<h1>Lisbon</h1>
<p>{{ weather.value.current.temperature_2m }}{{ weather.value.current_units.temperature_2m }}, wind {{ weather.value.current.wind_speed_10m }} {{ weather.value.current_units.wind_speed_10m }}</p>
@include("_partials/_layout-close.html.crisp")

Start the development server in another terminal, inside weather-now/:

crisptastic dev --bind 127.0.0.1:8080

Then, back in the first terminal:

curl -s http://127.0.0.1:8080/forecast | grep -E '<h1>|<p>'
<h1>Lisbon</h1>
<p>9.7°C, wind 4.2 km/h</p>

The grep is there because dev adds its live-reload script to every HTML page, which is long. Open http://127.0.0.1:8080/forecast in a browser and you will not notice it.

Notes:

  • @fetch must stay on one line, however long it gets.
  • The result is an envelope, and the parsed JSON is under .value, so the path is weather.value.current, not weather.current.
  • query: builds and encodes the query string for you. Do not glue values into the URL yourself.
  • There is no default timeout, only the 5-second limit on the whole page. Set timeout_ms so that a slow API gives you a message rather than a failed page.

5. When the fetch fails

The page above assumes all is well. Replace it with one that takes the place from the URL and checks .ok.

forecast.html.crisp
@let title = "Forecast"
@fetch weather = get_json("https://api.open-meteo.com/v1/forecast", query: { latitude: request.query.lat, longitude: request.query.lon, current: "temperature_2m,wind_speed_10m" }, timeout_ms: 3000)
@include("_partials/_layout-open.html.crisp")
@if (weather.ok) {
  <h1>{{ request.query.name }}</h1>
  <p>{{ weather.value.current.temperature_2m }}{{ weather.value.current_units.temperature_2m }}, wind {{ weather.value.current.wind_speed_10m }} {{ weather.value.current_units.wind_speed_10m }}</p>
} @else {
  @status 502
  <h1>No forecast right now</h1>
  <p>The weather service could not answer ({{ weather.error.code }}, status {{ weather.status }}).</p>
}
@include("_partials/_layout-close.html.crisp")

dev picks the change up by itself. Ask for a real place, then for a latitude the API refuses:

curl -si 'http://127.0.0.1:8080/forecast?lat=38.72&lon=-9.15&name=Lisbon' | grep -E '^HTTP|<h1>|<p>'
curl -si 'http://127.0.0.1:8080/forecast?lat=999&lon=0&name=Nowhere' | grep -E '^HTTP|<h1>|<p>'
HTTP/1.1 200 OK
  <h1>Lisbon</h1>
  <p>9.7°C, wind 4.2 km/h</p>
HTTP/1.1 502 Bad Gateway
  <h1>No forecast right now</h1>
  <p>The weather service could not answer (http_status, status 400).</p>

@fetch never stops the page. A failure is a value, so it is your page that decides what the visitor sees, and @status sets the response code while the body still renders. Without the check, the second request would have been a 200 with no weather on it, because a missing path prints as nothing.

@status takes its whole line, so it sits on its own line inside the block. If the closing } shared that line, the directive would swallow it.

To see the other ways a fetch fails, add a throwaway page.

failures.html.crisp
@fetch down = get_json("https://no-such-host.invalid/", timeout_ms: 3000)
@fetch local = get_json("http://127.0.0.1:8080/forecast")
<p>down: status [{{ down.status }}] code [{{ down.error.code }}]</p>
<p>local: status [{{ local.status }}] code [{{ local.error.code }}]</p>
curl -s http://127.0.0.1:8080/failures | grep '<p>'
rm failures.html.crisp
<p>down: status [] code [transport]</p>
<p>local: status [] code [security.ssrf_blocked]</p>

With nothing to report, status is null and prints as nothing. The second failure is a rule, not an accident: a page may only fetch https on port 443, and never a loopback or private address, even during development. See Fetching and shaping data.

6. Search by name, and reshape the answer

The search API returns tiny places along with the big ones. @shape runs an SDA query over a value and binds the result.

search.html.crisp
@if (!request.query.city) {
  @redirect "/"
}
@let title = "Search"
@fetch found = get_json("https://geocoding-api.open-meteo.com/v1/search", query: { name: request.query.city, count: 10 }, timeout_ms: 3000)
@include("_partials/_layout-open.html.crisp")
<h1>{{ request.query.city }}</h1>
@if (found.ok) {
  @if (found.value.results) {
    @shape towns = found.value.results with '{ yield r | r in A | r<population>? != null and r<population>? > 5000 }'
    @for (place in towns) {
      <p><a href="/forecast?{{= form_urlencode({ lat: place.latitude, lon: place.longitude, name: place.name }) }}">{{ place.name }}, {{ place.admin1 }}, {{ place.country }}</a></p>
    }
    @if (!towns) {
      <p>Only very small places have that name.</p>
    }
  } @else {
    @status 404
    <p>Nowhere goes by that name.</p>
  }
} @else {
  @status 502
  <p>The search service could not answer ({{ found.error.code }}).</p>
}
@include("_partials/_layout-close.html.crisp")

And a home page to start from.

index.html.crisp
@let title = "Home"
@include("_partials/_layout-open.html.crisp")
<h1>Weather Now</h1>
<form action="/search">
  <label>Place <input name="city" required></label>
  <button>Find</button>
</form>
@include("_partials/_layout-close.html.crisp")

The form uses the default GET, so a search is an ordinary link that can be shared and refreshed.

curl -si 'http://127.0.0.1:8080/search?city=Lisbon' | grep -E '^HTTP|<h1>|<p>'
curl -si 'http://127.0.0.1:8080/search?city=Tuktoyaktuk' | grep -E '^HTTP|<h1>|<p>'
curl -si 'http://127.0.0.1:8080/search?city=Xqzzyville' | grep -E '^HTTP|<h1>|<p>'
curl -si 'http://127.0.0.1:8080/search' | grep -E '^HTTP|^location'
HTTP/1.1 200 OK
<h1>Lisbon</h1>
      <p><a href="/forecast?lat=38.72509&amp;lon=-9.1498&amp;name=Lisbon">Lisbon, Lisbon District, Portugal</a></p>
      <p><a href="/forecast?lat=44.03146&amp;lon=-70.1045&amp;name=Lisbon">Lisbon, Maine, United States</a></p>
HTTP/1.1 200 OK
<h1>Tuktoyaktuk</h1>
      <p>Only very small places have that name.</p>
HTTP/1.1 404 Not Found
<h1>Xqzzyville</h1>
    <p>Nowhere goes by that name.</p>
HTTP/1.1 303 See Other
location: /

Reading the query:

  • A is the value you passed in. { yield r | r in A | ... } keeps each element r for which the condition holds. To keep one field, yield it: { yield r<name> | r in A }.
  • r<population>? is the optional selector, because some places have no population. Without the ? a missing key is an error.
  • The != null test comes first because comparing null with a number is also an error, and an error in a query is a 500, not a value you can branch on.
  • The query is fixed text in the page. You cannot build it from request values.

Only a small part of SDA is implemented: filtering and picking fields, not sorting or limiting. See What is implemented. Notice too that @shape is inside the @if: shaping a missing value fails the page.

form_urlencode builds the link's query string, with {{= }} escaping the result for the attribute. Open http://127.0.0.1:8080/, search for a place you know and follow a link.

7. Three days ahead

daily holds parallel arrays: time[0] goes with temperature_2m_max[0]. SDA cannot zip them and @for has no built-in index, but you can loop over the indexes themselves, since a path accepts a[i]. Replace the forecast page one last time.

forecast.html.crisp
@let title = "Forecast"
@fetch weather = get_json("https://api.open-meteo.com/v1/forecast", query: { latitude: request.query.lat, longitude: request.query.lon, current: "temperature_2m,wind_speed_10m", daily: "temperature_2m_max,temperature_2m_min", forecast_days: 3, timezone: "auto" }, timeout_ms: 3000)
@include("_partials/_layout-open.html.crisp")
@if (weather.ok) {
  @let days = weather.value.daily
  @let units = weather.value.daily_units
  <h1>{{ request.query.name }}</h1>
  <p>{{ weather.value.current.temperature_2m }}{{ weather.value.current_units.temperature_2m }}, wind {{ weather.value.current.wind_speed_10m }} {{ weather.value.current_units.wind_speed_10m }}</p>
  <table>
    <tr><th>Day</th><th>Low</th><th>High</th></tr>
    @for (i in [0, 1, 2]) {
      <tr><td>{{= days.time[i] }}</td><td>{{= days.temperature_2m_min[i] }}{{ units.temperature_2m_min }}</td><td>{{= days.temperature_2m_max[i] }}{{ units.temperature_2m_max }}</td></tr>
    }
  </table>
  <p><small>Answered at {{ weather.headers.date }}</small></p>
} @else {
  @status 502
  <h1>No forecast right now</h1>
  <p>The weather service could not answer ({{ weather.error.code }}, status {{ weather.status }}).</p>
}
@include("_partials/_layout-close.html.crisp")

{{ }} takes only a plain path, so anything with an index needs {{= }}. weather.headers holds the API's response headers, which is where the time at the bottom comes from.

curl -s 'http://127.0.0.1:8080/forecast?lat=38.72&lon=-9.15&name=Lisbon' | grep -E '<h1>|<p>|<td>'
  <h1>Lisbon</h1>
  <p>9.7°C, wind 4.2 km/h</p>
      <tr><td>2026-11-05</td><td>8.3°C</td><td>18.7°C</td></tr>
      <tr><td>2026-11-06</td><td>10.7°C</td><td>18.7°C</td></tr>
      <tr><td>2026-11-07</td><td>15.4°C</td><td>21.4°C</td></tr>
  <p><small>Answered at Thu, 05 Nov 2026 00:47:55 GMT</small></p>

8. Fetch less often

The page calls the API on every request, for data that changes every few minutes. [fetch] can keep answers in memory.

crisptastic.toml
[fetch]
cache_ttl_ms = 60000

Stop the server (Ctrl+C), because dev reads this file only at startup, and start it again:

crisptastic dev --bind 127.0.0.1:8080

Ask twice, three seconds apart, and look at the time:

curl -s 'http://127.0.0.1:8080/forecast?lat=38.72&lon=-9.15&name=Lisbon' | grep small
sleep 3
curl -s 'http://127.0.0.1:8080/forecast?lat=38.72&lon=-9.15&name=Lisbon' | grep small
  <p><small>Answered at Thu, 05 Nov 2026 00:47:59 GMT</small></p>
  <p><small>Answered at Thu, 05 Nov 2026 00:47:59 GMT</small></p>

The second answer never left the machine. Two things to know before you choose a number. Only GETs are cached, and the cache keeps a refused answer too: a 400 from the API is served again until the time is up, so keep the time short. And [fetch] applies to every @fetch on the site, including any headers or api_key you set there.

9. Check it

Stop the server (Ctrl+C) and run:

crisptastic check .
crisptastic routes .
ok: 3 routes, 2 partials
/
/forecast
/search

Three routes, and the two layout files are partials.

What next