Skip to content
JavaScript projects 6 min read

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}&current_weather=true`;
  const data = await getJSON(url);
  return data.current_weather; // { temperature, windspeed, weathercode, ... }
}

Tip: Always wrap user input in encodeURIComponent before 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}&current_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.

ApproachWhere the key livesSafe for production?
Hard-coded in JSBrowser bundleNo — exposed to everyone
import.meta.env / .env at buildStill in the shipped bundleNo — bundling does not hide it
Backend proxy / serverless functionServer onlyYes
Keyless API (Open-Meteo)NowhereYes

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.ok on every fetch — a 404 or 500 resolves, it does not reject.
  • Always encodeURIComponent user-supplied values before inserting them into a URL.
  • Drive the UI through explicit loading, success, and error states; use finally to reset spinners and buttons.
  • Disable the submit control while a request is in flight to avoid duplicate calls.
  • Use AbortController to 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.
Last updated June 1, 2026
Was this helpful?