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¤t=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
<!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>
</main>
</body>
</html>
Each page sets title with @let before it includes the first partial.
4. A first fetch
@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:
@fetchmust stay on one line, however long it gets.- The result is an envelope, and the parsed JSON is under
.value, so the path isweather.value.current, notweather.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_msso 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.
@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.
@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.
@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.
@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&lon=-9.1498&name=Lisbon">Lisbon, Lisbon District, Portugal</a></p>
<p><a href="/forecast?lat=44.03146&lon=-70.1045&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:
Ais the value you passed in.{ yield r | r in A | ... }keeps each elementrfor 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 nopopulation. Without the?a missing key is an error.- The
!= nulltest comes first because comparingnullwith a number is also an error, and an error in a query is a500, 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.
@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.
[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
- Cache rules, parallel fetches,
POSTrequests and everyerror.code: Fetching and shaping data. - Keep your own data as well as reading other people's: A guestbook with its own store.
- Put this on a server: Ship it. A
GETpage may keep using@fetchwithget_jsonthere, but a production host refuses other methods during aGET; see Sessions and forms.