Project: Weather App with Fetch
A weather app is the classic project for learning how the browser talks to the outside world. You take a city name from an input, ask a public HTTP API for current conditions, and paint the result on the page — all without reloading. Along the way you’ll practice the three skills every networked UI needs: firing a request with fetch, awaiting it with async/await, and gracefully handling the loading and error states in between. We’ll use the free, no-signup Open-Meteo API so every snippet runs as-is.
How the data flows
Before writing code, picture the round trip. The user types a city, you geocode it to latitude/longitude, then request the forecast for those coordinates, and finally render it. Each step is a network call that can succeed, fail, or stall.
[input: "Tokyo"]
│ geocoding API
▼
{ latitude: 35.68, longitude: 139.69, name: "Tokyo" }
│ forecast API
▼
{ temperature: 22.4, windspeed: 9.1, weathercode: 3 }
│ render
▼
[card: Tokyo · 22.4°C · Overcast]
Fetching JSON the modern way
fetch returns a promise that resolves to a Response object. A common beginner trap: fetch only rejects on a network failure (DNS, offline, CORS). An HTTP 404 or 500 still resolves successfully, so you must check response.ok yourself. The body is then parsed with await response.json().
async function getJSON(url) {
const res = await fetch(url);
if (!res.ok) {
throw new Error(`Request failed: ${res.status} ${res.statusText}`);
}
return res.json();
}
We’ll build two small helpers on top of that — one to turn a city name into coordinates, one to fetch the forecast.
async function geocodeCity(city) {
const url = `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(city)}&count=1`;
const data = await getJSON(url);
if (!data.results?.length) throw new Error(`No match for "${city}"`);
const { latitude, longitude, name, country } = data.results[0];
return { latitude, longitude, label: `${name}, ${country}` };
}
async function getWeather(lat, lon) {
const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}¤t_weather=true`;
const data = await getJSON(url);
return data.current_weather; // { temperature, windspeed, weathercode, ... }
}
Tip: Always wrap user input in
encodeURIComponentbefore putting it in a URL. Without it, a city like “São Paulo” or any string with spaces or&produces a malformed request.
A quick console test ties them together:
const place = await geocodeCity("Paris");
const weather = await getWeather(place.latitude, place.longitude);
console.log(place.label, weather.temperature + "°C");
Output:
Paris, France 18.6°C
Loading and error states
A real UI must communicate what’s happening. The pattern is always the same: show a spinner before the await, render the result on success, and show a message on failure inside a try/catch. A finally block is the right place to hide the spinner because it runs whether the call succeeded or threw.
async function loadWeather(city, ui) {
ui.setLoading(true);
ui.clearError();
try {
const place = await geocodeCity(city);
const weather = await getWeather(place.latitude, place.longitude);
ui.render(place.label, weather);
} catch (err) {
ui.showError(err.message);
} finally {
ui.setLoading(false);
}
}
The full interactive app
Here is the complete app in one self-contained file: an input, a search button, a loading indicator, an error region, and a result card. Open it in CodePen and search for any city.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<style>
body { font-family: system-ui, sans-serif; max-width: 420px; margin: 2rem auto; padding: 0 1rem; }
.row { display: flex; gap: .5rem; }
input { flex: 1; padding: .6rem; font-size: 1rem; }
button { padding: .6rem 1rem; cursor: pointer; }
#status { min-height: 1.4rem; margin: .75rem 0; color: #555; }
#status.error { color: #c0392b; }
.card { display: none; padding: 1rem; border: 1px solid #ddd; border-radius: 12px; background: #f7fbff; }
.card.visible { display: block; }
.temp { font-size: 2.5rem; font-weight: 700; }
</style>
</head>
<body>
<h1>Weather</h1>
<div class="row">
<input id="city" placeholder="Enter a city…" value="London" />
<button id="go">Search</button>
</div>
<div id="status"></div>
<div class="card" id="card">
<div id="place"></div>
<div class="temp" id="temp"></div>
<div id="desc"></div>
</div>
<script>
const codes = {
0: "Clear sky", 1: "Mainly clear", 2: "Partly cloudy", 3: "Overcast",
45: "Fog", 51: "Light drizzle", 61: "Rain", 71: "Snow", 80: "Rain showers",
95: "Thunderstorm",
};
const els = {
city: document.getElementById("city"),
go: document.getElementById("go"),
status: document.getElementById("status"),
card: document.getElementById("card"),
place: document.getElementById("place"),
temp: document.getElementById("temp"),
desc: document.getElementById("desc"),
};
async function getJSON(url) {
const res = await fetch(url);
if (!res.ok) throw new Error(`Request failed (${res.status})`);
return res.json();
}
async function search() {
const city = els.city.value.trim();
if (!city) return;
els.status.textContent = "Loading…";
els.status.classList.remove("error");
els.card.classList.remove("visible");
els.go.disabled = true;
try {
const geo = await getJSON(
`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(city)}&count=1`
);
if (!geo.results?.length) throw new Error(`No match for "${city}"`);
const { latitude, longitude, name, country } = geo.results[0];
const data = await getJSON(
`https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}¤t_weather=true`
);
const w = data.current_weather;
els.place.textContent = `${name}, ${country}`;
els.temp.textContent = `${w.temperature}°C`;
els.desc.textContent = `${codes[w.weathercode] ?? "Unknown"} · wind ${w.windspeed} km/h`;
els.card.classList.add("visible");
els.status.textContent = "";
} catch (err) {
els.status.textContent = err.message;
els.status.classList.add("error");
} finally {
els.go.disabled = false;
}
}
els.go.addEventListener("click", search);
els.city.addEventListener("keydown", (e) => { if (e.key === "Enter") search(); });
search();
</script>
</body>
</html>
The weathercode is a numeric enum from the API; the codes lookup table maps it to readable text. Disabling the button during the request (els.go.disabled = true) prevents a user from firing a dozen overlapping calls by clicking rapidly.
Cancelling stale requests
If a user searches quickly, an earlier slow response can arrive after a later one and overwrite the correct result. AbortController lets you cancel the previous request before starting a new one.
function makeSearcher(fetchFn) {
let controller = null;
return async (url) => {
if (controller) controller.abort(); // cancel the in-flight request
controller = new AbortController();
try {
const res = await fetchFn(url, { signal: controller.signal });
return await res.json();
} catch (err) {
if (err.name === "AbortError") return null; // expected on cancel
throw err;
}
};
}
const search = makeSearcher(fetch);
console.log(typeof search); // "function"
Output:
function
Handling API keys in client apps
Open-Meteo needs no key, but most weather providers (OpenWeatherMap, WeatherAPI) do. Anything you put in client-side JavaScript is fully visible to users — never embed a secret key in browser code.
| Approach | Where the key lives | Safe for production? |
|---|---|---|
| Hard-coded in JS | Browser bundle | No — exposed to everyone |
import.meta.env / .env at build | Still in the shipped bundle | No — bundling does not hide it |
| Backend proxy / serverless function | Server only | Yes |
| Keyless API (Open-Meteo) | Nowhere | Yes |
Warning: Build-time environment variables are not a security feature for browser apps. They are inlined into the JavaScript that ships to the client. The only safe place for a private key is a server you control that proxies the request.
Best practices
- Check
response.okon everyfetch— a 404 or 500 resolves, it does not reject. - Always
encodeURIComponentuser-supplied values before inserting them into a URL. - Drive the UI through explicit loading, success, and error states; use
finallyto reset spinners and buttons. - Disable the submit control while a request is in flight to avoid duplicate calls.
- Use
AbortControllerto cancel superseded requests so stale responses cannot overwrite fresh ones. - Keep private API keys on a server; treat anything in the browser bundle as public.
- Map raw API enums (like
weathercode) to human-readable text in one lookup table.