Skip to content
Happy Programming Guide
Start learning
Programming Projects

JavaScript Project: Build a Weather App

Fetch live data and display it properly — including the loading and error states most tutorials skip. Built so it works with whichever weather API you choose.

Colourful plastic building bricks on a table

This is the project where you connect to the outside world. The interesting part is not the weather — it is handling the three things that can happen when you ask another server for data: it works, it is still working, or it failed.

The HTML#

HTML
<main class="app">
  <h1>Weather</h1>

  <form id="search">
    <label class="sr" for="city">City</label>
    <input id="city" type="text" placeholder="Lahore" required autocomplete="off">
    <button type="submit">Check</button>
  </form>

  <div id="status" role="status" aria-live="polite"></div>

  <section id="result" hidden>
    <h2 id="place"></h2>
    <p id="temp" class="temp"></p>
    <p id="desc"></p>
    <dl id="detail"></dl>
  </section>
</main>

<script src="weather.js" defer></script>

Two details that matter more than they look:

  • role="status" with aria-live="polite" means a screen reader announces “Loading” and any error without the user having to go looking for it.
  • The result section starts hidden and we toggle .hidden in JavaScript, rather than fighting with style.display.

Step 1: the three states#

Write these before writing any fetch code. If the states are right, the rest is straightforward.

JavaScript
const form = document.querySelector("#search");
const input = document.querySelector("#city");
const statusEl = document.querySelector("#status");
const result = document.querySelector("#result");

function showLoading(city) {
  statusEl.textContent = `Looking up ${city}…`;
  result.hidden = true;
}

function showError(message) {
  statusEl.textContent = message;
  result.hidden = true;
}

function showWeather(data) {
  statusEl.textContent = "";
  result.hidden = false;

  document.querySelector("#place").textContent = data.place;
  document.querySelector("#temp").textContent = `${Math.round(data.temperature)}°`;
  document.querySelector("#desc").textContent = data.description;

  document.querySelector("#detail").innerHTML = `
    <dt>Feels like</dt><dd>${Math.round(data.feelsLike)}°</dd>
    <dt>Humidity</dt><dd>${data.humidity}%</dd>
    <dt>Wind</dt><dd>${data.wind} km/h</dd>
  `;
}

Notice showWeather reads data.place, data.temperature and so on — names we chose. Nothing here knows which provider the data came from.

Step 2: the adapter#

This is the only function that changes if you switch providers. Open your API’s documentation, make one real request in the browser address bar, look at what comes back, and map it:

JavaScript
// Fill these in from YOUR provider's documented response.
// Make one real request first and look at the actual JSON.
function normalise(raw) {
  return {
    place:       raw.name,
    temperature: raw.main.temp,
    feelsLike:   raw.main.feels_like,
    humidity:    raw.main.humidity,
    wind:        raw.wind.speed,
    description: raw.weather[0].description,
  };
}

Step 3: fetching#

JavaScript
const API_BASE = "https://api.example.com/weather";
const API_KEY = "your-key-here";

async function getWeather(city) {
  const url = `${API_BASE}?q=${encodeURIComponent(city)}&units=metric&appid=${API_KEY}`;

  const response = await fetch(url);

  if (response.status === 404) {
    throw new Error(`No city called "${city}" was found.`);
  }
  if (response.status === 401) {
    throw new Error("The API key was rejected.");
  }
  if (!response.ok) {
    throw new Error(`The weather service returned ${response.status}.`);
  }

  return normalise(await response.json());
}

Three things worth pointing at:

  • fetch does not throw on a 404 or a 500. It only rejects when the request could not be made at all. You must check response.ok or a specific status yourself — see the Fetch API guide.
  • encodeURIComponent handles cities with spaces and accents. Without it, “New York” breaks the URL.
  • Turning statuses into messages here keeps the user-facing wording in one place, and each one says what the person can do about it.

Step 4: wiring it up#

JavaScript
let inFlight = null;

form.addEventListener("submit", async (event) => {
  event.preventDefault();

  const city = input.value.trim();
  if (!city) return;

  // Cancel an earlier request so a slow answer cannot overwrite a newer one.
  if (inFlight) inFlight.abort();
  inFlight = new AbortController();

  showLoading(city);

  try {
    const data = await getWeather(city, inFlight.signal);
    showWeather(data);
  } catch (err) {
    if (err.name === "AbortError") return;      // superseded, not a failure
    showError(err.message || "Something went wrong. Please try again.");
    console.error(err);
  }
});

To make the abort work, pass the signal through:

JavaScript
async function getWeather(city, signal) {
  const response = await fetch(url, { signal });
  // ...
}

Without this, someone who searches twice quickly can see the first (slower) result land on top of the second. It is a real bug in a lot of tutorial weather apps.

The CSS#

CSS
.app {
  max-width: 420px;
  margin: 48px auto;
  padding: 24px;
  border-radius: 16px;
  background: #fff;
  box-shadow: 0 4px 20px rgba(0, 0, 0, .07);
  font-family: system-ui, sans-serif;
}

#search { display: flex; gap: 8px; margin-bottom: 20px; }
#city { flex: 1; padding: 10px 12px; border: 1px solid #ddd; border-radius: 8px; font: inherit; }
button { padding: 10px 18px; border: 0; border-radius: 8px; background: #4f46e5; color: #fff; font: inherit; cursor: pointer; }

#status { min-height: 1.4em; color: #666; font-size: .9rem; }
.temp { font-size: 3rem; margin: 4px 0; font-weight: 700; }

#detail { display: grid; grid-template-columns: auto 1fr; gap: 4px 16px; margin-top: 16px; font-size: .9rem; }
#detail dt { color: #777; }
#detail dd { margin: 0; }

.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }

#status has a min-height so the layout does not jump when the message appears and disappears.

Questions people ask#

Which weather API should I use?

Several offer a free tier, and some need no key at all. Pick one whose documentation you find readable — you will be looking at it a lot. Check whether it allows requests directly from a browser, because some do not, which produces a CORS error you cannot fix from the front end.

I get a CORS error

The provider has not permitted browser requests from other origins. You cannot work around this in your JavaScript. Either pick an API that allows it, or make the call from your own server.

Why is my temperature showing as undefined?

Your normalise() is reading a field that does not exist in the response. Log the raw JSON and compare it against the names you are using.

Should I use axios instead of fetch?

Not for this. fetch is built in and does everything here. Axios adds conveniences you will appreciate later on larger projects.

Where to go next#

Related lessonJavaScript Fetch API explained

Keep reading

Keep going — pick your next guide

The fastest way to improve is to read one guide, then build the thing it describes. Start with the basics, or jump straight to a project.

Ask a question or share what worked

Your email address will not be published. Required fields are marked *