Skip to main content

β›… Weekend Project: Build a Weather App with a Public API

This is the project where your JavaScript reaches out and touches the real world. You'll build a weather app that asks a live public API for the current conditions in any city and paints the answer on screen β€” temperature, humidity, wind, the works. Along the way you'll practice the single most important skill in modern front-end development: fetching data over the network with async/await and handling everything that can go wrong while you wait.

Week 2 · Weekend Project · API Build

🎯 Learning Objectives

By completing this project, you will be able to:

  • Register for a public API and call it correctly with a query string and API key
  • Fetch remote JSON with fetch() and async/await, checking response.ok before trusting the result
  • Map a nested API response onto DOM elements to render current conditions
  • Drive three UI states β€” loading, success, and error β€” from a single search flow
  • Translate HTTP status codes (404, 401, 429, 5xx) into clear, human error messages with try/catch
  • Explain why a client-side API key is exposed, and keep it out of your Git history

Estimated Time: 4–6 hours across the weekend

Project: A responsive, deployable weather app that searches any city and shows live conditions.

In This Project

The Goal

Build a single-page weather app that a real visitor could use: type a city, hit search, and see the current weather appear a moment later. It should feel responsive on a phone and a laptop, show a spinner while it waits, and give a friendly message when something goes wrong instead of leaving the user staring at a blank screen.

The heart of this project is a data flow: user input travels out to a server on the internet, comes back as JSON, and gets translated into pixels. Keep this diagram in your head as you build β€” every stage below is one arrow in it.

graph LR A[User types a city
and submits] --> B[Show loading spinner] B --> C["fetch() the API
with city + key"] C --> D{response.ok?} D -->|Yes| E[Parse JSON] D -->|No / network error| F[Show error message] E --> G[Render conditions
into the DOM] G --> H[Hide spinner] F --> H

We'll use OpenWeatherMap as the public API because its free tier is generous and its "current weather" endpoint returns everything we need in one call. The technique, though, is universal β€” swap in any REST API and the same fetch/await/ok/catch pattern applies.

Prerequisites

This is the Week 2 capstone, so it leans on everything from the "JavaScript Deep Dive" week. Before you start, make sure you're comfortable with:

  • DOM selection & events β€” getElementById, querySelector, and addEventListener("submit", …)
  • Promises & async/await β€” you can read an async function and know that await pauses until a promise settles
  • Template literals β€” building strings with `${value}`
  • Objects & property access β€” reading nested data like data.main.temp
  • Error handling β€” try / catch / finally and throw new Error(...) (fresh from the "Common JavaScript Errors" lesson)

You'll also need a code editor, a modern browser, and a way to serve files locally (VS Code's Live Server extension is perfect). You do not need Node.js, a framework, or any build tools for this build.

Required Features Checklist

These are the non-negotiables. Every one is achievable with plain, vanilla JavaScript β€” no libraries. Tick each off as you go.

βœ… Must-have features

  • ☐ A search form with a text input for the city name
  • ☐ Fetches live weather from a public API using async/await
  • ☐ Displays current conditions: city name, temperature, description + icon, humidity, wind, "feels like"
  • ☐ A visible loading indicator while the request is in flight
  • ☐ Graceful error handling for a wrong city, a bad key, and a dropped connection
  • ☐ Checks response.ok and never renders unverified data
  • ☐ A working Β°C / Β°F units toggle
  • ☐ Responsive layout that looks good from ~360px up to desktop
  • ☐ External CSS and JS files; the API key kept out of Git

Getting Your API Key (Safely)

An API key is a password-like string that identifies your app to the weather service so it can count your requests. Getting one is free and takes a couple of minutes:

  1. Go to openweathermap.org and create a free account.
  2. Open the API keys tab in your account dashboard.
  3. Copy the default key (or generate a new one). Newly created keys can take a little while β€” sometimes up to an hour β€” to activate, so don't panic if it returns 401 at first.

⚠️ Read this before you paste your key anywhere

Any key you put in front-end JavaScript is fully visible to anyone who opens DevTools and looks at the Network tab. There is no way to hide it in client-side code. For this learning project that's acceptable β€” the free tier is rate-limited and low-risk β€” but you must treat the key like it's public:

  • Never commit the key to Git. Put it in a separate js/config.js file and add that file to .gitignore before your first commit.
  • If you accidentally push it, revoke and regenerate it β€” deleting the commit is not enough, because it lives in your history.
  • For a real production app, the key belongs on a backend server (or a serverless proxy) that makes the API call for you and never ships the key to the browser. You'll learn to build exactly that later in the course.

Create your ignored config file like this:

// js/config.js  β€”  add this filename to .gitignore!
// This file holds your personal key and should never be committed.

const API_KEY = "paste-your-own-key-here";
const BASE_URL = "https://api.openweathermap.org/data/2.5/weather";

And your .gitignore:

# Keep secrets out of version control
js/config.js

πŸ’‘ A friendlier team habit

Commit a js/config.example.js with a placeholder key instead. Teammates copy it to config.js and drop in their own key. The real file stays ignored; the example documents what's needed.

Starter Project Structure

Create these folders and files first. Keeping HTML, CSS, and JS in separate files is the professional default β€” each concern in its own place, and easy to cache.

weather-app/
β”œβ”€β”€ index.html
β”œβ”€β”€ .gitignore          <-- ignores js/config.js
β”œβ”€β”€ styles/
β”‚   └── style.css
└── js/
    β”œβ”€β”€ config.js       <-- your API key (git-ignored)
    β”œβ”€β”€ config.example.js
    └── app.js          <-- all the logic

πŸ’‘ Momentum over polish

Get a bare page fetching data first, then make it pretty. A working ugly app beats a beautiful one that doesn't fetch. Build in the order of the stages below.

Stage 1 β€” HTML Structure

Start with the markup, no styling. You need three things: a search form, three status containers (loading, error, and the weather card), and the elements inside the card that you'll fill with data. Give everything an id so JavaScript can find it. Here's a solid index.html:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Weather App</title>
    <link rel="stylesheet" href="styles/style.css">
</head>
<body>
    <div class="container">
        <h1>β›… Weather App</h1>

        <form id="searchForm" class="search-box">
            <label for="cityInput" class="visually-hidden">City name</label>
            <input type="text" id="cityInput" placeholder="Enter a city…"
                   autocomplete="off" required>
            <button type="submit">Search</button>
        </form>

        <!-- Three status regions. All hidden until we need them. -->
        <p id="loading" class="status" role="status" hidden>Loading weather…</p>
        <p id="error" class="status error" role="alert" hidden></p>

        <section id="weatherCard" class="weather-card" hidden>
            <header>
                <h2 id="cityName"></h2>
                <p id="dateTime"></p>
            </header>

            <div class="temp-row">
                <img id="weatherIcon" src="" alt="">
                <span id="temperature" class="temp"></span>
                <div class="unit-toggle" role="group" aria-label="Temperature units">
                    <button id="celsiusBtn" class="active" type="button">Β°C</button>
                    <button id="fahrenheitBtn" type="button">Β°F</button>
                </div>
            </div>

            <p id="description" class="description"></p>
            <p>Feels like <span id="feelsLike"></span></p>

            <div class="details">
                <div>πŸ’§ Humidity<br><span id="humidity"></span></div>
                <div>πŸ’¨ Wind<br><span id="windSpeed"></span></div>
                <div>🌑️ Pressure<br><span id="pressure"></span></div>
            </div>
        </section>
    </div>

    <!-- config.js MUST load first so app.js can see API_KEY -->
    <script src="js/config.js"></script>
    <script src="js/app.js"></script>
</body>
</html>

πŸ“– Why the hidden attribute?

The three status regions all start with the HTML hidden attribute, which is just display: none the browser gives you for free. Your JavaScript will flip these on and off β€” showing exactly one at a time β€” as the request moves through its lifecycle. Using a real attribute (instead of inline styles) keeps the "what state am I in?" logic in one obvious place.

Stage 2 β€” Fetch from the API

Now the important part. Create js/app.js and start by grabbing your DOM elements, then write the function that talks to the network. This is the pattern you'll reuse for the rest of your career: await fetch(), check response.ok, then await response.json().

Grab the elements and wire the form

// --- DOM references ---
const form        = document.getElementById("searchForm");
const cityInput   = document.getElementById("cityInput");
const loadingEl   = document.getElementById("loading");
const errorEl     = document.getElementById("error");
const weatherCard = document.getElementById("weatherCard");

// Elements inside the card that we fill with data
const els = {
  cityName:    document.getElementById("cityName"),
  dateTime:    document.getElementById("dateTime"),
  temperature: document.getElementById("temperature"),
  weatherIcon: document.getElementById("weatherIcon"),
  description: document.getElementById("description"),
  feelsLike:   document.getElementById("feelsLike"),
  humidity:    document.getElementById("humidity"),
  windSpeed:   document.getElementById("windSpeed"),
  pressure:    document.getElementById("pressure"),
};

// App state: keep the raw data so the units toggle can re-render it later
let currentData = null;
let useCelsius = true;

form.addEventListener("submit", handleSearch);

The fetch function β€” check response.ok first

A fetch() promise only rejects on a network failure. A 404 or 401 is still a "successful" round-trip as far as fetch is concerned β€” so you must inspect response.ok yourself and throw a helpful error. This is the #1 mistake beginners make with fetch.

// Build the request URL and return parsed weather JSON β€” or throw.
async function fetchWeather(city) {
  // encodeURIComponent handles spaces & accents: "SΓ£o Paulo" β†’ "S%C3%A3o%20Paulo"
  const url = `${BASE_URL}?q=${encodeURIComponent(city)}`
            + `&appid=${API_KEY}&units=metric`;

  let response;
  try {
    response = await fetch(url);
  } catch {
    // fetch() only rejects for network-level failures (offline, DNS, CORS)
    throw new Error("Network error β€” check your internet connection.");
  }

  // A reached-but-unhappy server still resolves, so verify the status.
  if (!response.ok) {
    if (response.status === 404) throw new Error(`Couldn't find "${city}". Check the spelling.`);
    if (response.status === 401) throw new Error("API key rejected. It may still be activating.");
    if (response.status === 429) throw new Error("Too many requests β€” slow down and try again.");
    if (response.status >= 500) throw new Error("The weather service is having trouble. Try again later.");
    throw new Error("Something went wrong fetching the weather.");
  }

  return response.json(); // resolves to the parsed object
}

⚠️ The units query parameter matters

We request units=metric so the API returns Celsius directly. (Leave it off and you get Kelvin β€” a classic "why is it 293 degrees?!" bug.) We'll convert to Fahrenheit ourselves in the toggle so a single request covers both units.

Here's the shape of what comes back, so you know which properties to reach for in the next stage:

{
  "name": "London",
  "sys":  { "country": "GB" },
  "weather": [
    { "description": "scattered clouds", "icon": "03d" }
  ],
  "main": {
    "temp": 15.5,
    "feels_like": 14.8,
    "humidity": 67,
    "pressure": 1013
  },
  "wind": { "speed": 4.1 }
}

Stage 3 β€” Render Current Conditions

Fetching is half the job; the other half is turning that nested object into things a person can read. The render function walks the response and writes each value into its element. Notice how naturally the API's structure maps onto the DOM β€” data.main.temp becomes the big number, data.weather[0].description becomes the caption, and so on.

function render(data) {
  els.cityName.textContent = `${data.name}, ${data.sys.country}`;

  els.dateTime.textContent = new Date().toLocaleDateString("en-US", {
    weekday: "long", month: "long", day: "numeric",
    hour: "2-digit", minute: "2-digit",
  });

  // The icon code maps to an official image; @2x is the retina version.
  const { icon, description } = data.weather[0];
  els.weatherIcon.src = `https://openweathermap.org/img/wn/${icon}@2x.png`;
  els.weatherIcon.alt = description;          // meaningful alt text for a11y
  els.description.textContent = description;

  els.humidity.textContent = `${data.main.humidity}%`;
  els.windSpeed.textContent = `${data.wind.speed} m/s`;
  els.pressure.textContent = `${data.main.pressure} hPa`;

  updateTemperature();   // fills temp + "feels like" using current unit
}

// --- Temperature + units toggle ---
const toF = (c) => (c * 9) / 5 + 32;
const fmt = (c) => useCelsius
  ? `${Math.round(c)}Β°C`
  : `${Math.round(toF(c))}Β°F`;

function updateTemperature() {
  if (!currentData) return;
  els.temperature.textContent = fmt(currentData.main.temp);
  els.feelsLike.textContent   = fmt(currentData.main.feels_like);
}

function setUnit(toCelsius) {
  if (useCelsius === toCelsius) return;   // no change, no work
  useCelsius = toCelsius;
  celsiusBtn.classList.toggle("active", useCelsius);
  fahrenheitBtn.classList.toggle("active", !useCelsius);
  updateTemperature();                    // re-render from the SAME data
}

const celsiusBtn    = document.getElementById("celsiusBtn");
const fahrenheitBtn = document.getElementById("fahrenheitBtn");
celsiusBtn.addEventListener("click", () => setUnit(true));
fahrenheitBtn.addEventListener("click", () => setUnit(false));

βœ… Convert on the client, don't re-fetch

Because we stored the raw Celsius value in currentData, flipping to Fahrenheit is instant and free β€” no second network request. Storing data in state and re-deriving the view from it is a core idea you'll meet again the moment you reach React.

Stage 4 β€” Loading & Error States

The last piece ties it together. handleSearch orchestrates the whole flow from the diagram at the top: show the spinner, call the API, render on success, show a message on failure, and β€” crucially β€” hide the spinner no matter what, using finally.

async function handleSearch(event) {
  event.preventDefault();                 // stop the form from reloading the page
  const city = cityInput.value.trim();
  if (!city) return;                      // ignore empty submits

  showOnly(loadingEl);                    // enter the "loading" state

  try {
    const data = await fetchWeather(city);
    currentData = data;                   // save for the units toggle
    render(data);
    showOnly(weatherCard);                // success!
  } catch (err) {
    errorEl.textContent = err.message;    // the friendly message we threw
    showOnly(errorEl);                    // failure
  }
}

// Show exactly one of the three regions; hide the rest.
function showOnly(elementToShow) {
  for (const el of [loadingEl, errorEl, weatherCard]) {
    el.hidden = el !== elementToShow;
  }
}

// Start clean
cityInput.focus();

That's a complete, working weather app in well under 100 lines of JavaScript. Open index.html through Live Server (not file://, or the icon images may be blocked), search "Tokyo", and watch the whole data-flow diagram run in real time.

The three mutually-exclusive UI states: loading, error, and success Loading ⏳ request in flight Error ⚠️ show a message Success β˜€οΈ render the card showOnly() guarantees exactly one is visible at a time
A single search moves through these states. showOnly() is what keeps them mutually exclusive.

Stretch Goals

Finished the required build with time to spare? Level it up. Pick whichever excites you β€” none are needed to pass the rubric.

  • πŸ“… 5-day forecast β€” call the /forecast endpoint and render a row of daily cards
  • πŸ“ Geolocation β€” a "use my location" button that fetches by coordinates
  • πŸ•˜ Search history β€” remember the last five cities with localStorage
  • πŸŒ™ Dark mode β€” a theme toggle that persists the choice
  • 🎨 Dynamic backgrounds β€” change the page gradient based on the weather condition

Geolocation starter

The browser's navigator.geolocation is asynchronous with callbacks. Note it only works over HTTPS or localhost, and always requires user permission:

function useMyLocation() {
  if (!navigator.geolocation) {
    errorEl.textContent = "Geolocation isn't supported by this browser.";
    showOnly(errorEl);
    return;
  }
  showOnly(loadingEl);
  navigator.geolocation.getCurrentPosition(
    async ({ coords }) => {              // success callback
      try {
        const url = `${BASE_URL}?lat=${coords.latitude}`
                  + `&lon=${coords.longitude}&appid=${API_KEY}&units=metric`;
        const res = await fetch(url);
        if (!res.ok) throw new Error("Couldn't get weather for your location.");
        currentData = await res.json();
        render(currentData);
        showOnly(weatherCard);
      } catch (err) {
        errorEl.textContent = err.message;
        showOnly(errorEl);
      }
    },
    () => {                              // error callback (permission denied, etc.)
      errorEl.textContent = "Location permission denied.";
      showOnly(errorEl);
    }
  );
}

Search-history starter

function rememberCity(city) {
  let history = JSON.parse(localStorage.getItem("recentCities") || "[]");
  history = [city, ...history.filter(c => c.toLowerCase() !== city.toLowerCase())];
  history = history.slice(0, 5);         // keep the five most recent
  localStorage.setItem("recentCities", JSON.stringify(history));
}

Self-Check Rubric

Before you call this done, grade yourself against the rubric. Aim to answer "yes" to everything in the first two columns β€” the stretch column is bonus.

AreaMeets expectations (required)Exceeds (stretch)
Data fetching Uses fetch + async/await; checks response.ok; awaits .json() Also fetches a forecast or by geolocation
Rendering City, temp, description + icon, humidity, wind, feels-like all display correctly Dynamic background or icon-driven theming
UI states Loading, error, and success are mutually exclusive and clearly signalled Smooth transitions; a spinner animation
Error handling Wrong city, bad key, and offline each show a distinct, friendly message Handles 429/5xx and retries thoughtfully
Units toggle Β°C / Β°F switches instantly without re-fetching Remembers the user's preferred unit
Security & quality Key in a git-ignored config.js; external CSS/JS; no console errors Deployed live; documented key-setup in the README

πŸ§ͺ Final testing checklist

  • ☐ "London", "New York", "Tokyo" all return correct data
  • ☐ A city with spaces/accents ("SΓ£o Paulo") works β€” thanks to encodeURIComponent
  • ☐ A nonsense city ("Xyzabc") shows the not-found message, not a crash
  • ☐ Submitting an empty field does nothing (no API call)
  • ☐ Turning off Wi-Fi and searching shows the network-error message
  • ☐ The Β°C / Β°F toggle updates both temperature and "feels like"
  • ☐ The layout is clean from ~360px up to full desktop width
  • ☐ No red errors in the browser console
  • ☐ Your API key is not in any committed file

Summary

πŸŽ‰ What You Built

  • A live weather app that fetches real JSON from a public API with modern async/await
  • A robust request that checks response.ok and turns HTTP status codes into human messages via try/catch
  • A clean three-state UI β€” loading, error, success β€” driven from one search flow
  • A units toggle that re-derives the view from stored state instead of re-fetching
  • A responsible API-key workflow that keeps secrets out of Git

This project is proof that Week 2 stuck. You took asynchronous JavaScript, error handling, and DOM work and combined them into an app that reaches across the internet and shows a stranger something useful. Talking to APIs is the beating heart of every real front end β€” you now know the pattern.

πŸ“š Additional Resources

πŸš€ What's Next?

Week 2 is complete β€” you can now store data, transform it, and fetch it from the wider world. Next week we tighten up the language itself, starting with the syntax you've already been leaning on in every callback and arrow: Arrow Functions and Template Literals. You'll learn exactly what => does to this and how to write cleaner, more expressive code.

πŸŽ‰ You finished Week 2!

You shipped an app that talks to the internet. Deploy it, share the URL, and add it next to your portfolio β€” that's two real projects now.