Complete JavaScript Course Module 16: Advanced: Professional JavaScript

Final Project: Build a Weather App with JavaScript and an API

  • Beginner
  • Lesson 16.6
  • 10 min read
  • 76 of 76 in the course

In short

In this final project, you will build a weather app with JavaScript and a free weather API. It searches a city, shows current weather and a 5-day forecast, and remembers your last city. What you will build and which APIs you will use Your weather app will: Let the user search for a city.

In this final project, you will build a weather app with JavaScript and a free weather API. It searches a city, shows current weather and a 5-day forecast, and remembers your last city.

What you will build and which APIs you will use

Your weather app will:

  • Let the user search for a city.
  • Show the current temperature, weather and wind speed.
  • Show a 5-day forecast as cards.
  • Show "loading" and friendly error messages.
  • Remember the last city in localStorage and load it next time.

We use two free APIs from Open-Meteo. They need no account and no API key, which is perfect for learning.

  1. The Geocoding API turns a city name into coordinates (latitude and longitude): https://geocoding-api.open-meteo.com/v1/search?name=Lisbon
  2. The Forecast API returns weather for those coordinates: https://api.open-meteo.com/v1/forecast?latitude=..&longitude=..&current=temperature_2m,weather_code,wind_speed_10m&daily=temperature_2m_max,temperature_2m_min,weather_code&timezone=auto

Paste the first address into your browser with a real city to see the JSON it returns. You will find a results array, and each result has name, country, latitude and longitude.

We will split the code into ES modules, each with one job:

weather-app/
├── index.html
├── style.css
└── js/
    ├── main.js            starts the app and handles the form
    ├── api.js             talks to the two APIs
    ├── ui.js              builds and updates the page
    └── weather-codes.js   turns weather codes into text and emoji

Note: ES modules do not load from a file opened with file://. Run the project with a local server: the VS Code "Live Server" extension, Vite from the previous lesson, or npx serve in the project folder.

Step 1: The HTML and CSS

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Weather App</title>
  <link rel="stylesheet" href="style.css">
  <script type="module" src="js/main.js"></script>
</head>
<body>
  <main class="app">
    <h1>Weather</h1>

    <form id="search-form" class="search">
      <label for="city-input" class="visually-hidden">City name</label>
      <input id="city-input" type="search" placeholder="Search a city, e.g. Lisbon" required autocomplete="off">
      <button id="search-button" type="submit">Search</button>
    </form>

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

    <section id="current" class="current" hidden></section>

    <section id="forecast" hidden>
      <h2>5-day forecast</h2>
      <div id="forecast-cards" class="forecast-cards"></div>
    </section>

    <p class="credit">Weather data by <a href="https://open-meteo.com/" target="_blank" rel="noopener noreferrer">Open-Meteo</a></p>
  </main>
</body>
</html>

The hidden attribute keeps the weather sections invisible until we have data. The status paragraph has role="status" and aria-live="polite", so screen readers announce loading and error messages. Module scripts wait for the HTML automatically, so the script can stay in the <head>.

* { box-sizing: border-box; }
body { margin: 0; font-family: system-ui, sans-serif; background: linear-gradient(160deg, #4f8ef7, #9bc4ff); color: #10223d; min-height: 100vh; }
.app { max-width: 720px; margin: 0 auto; padding: 2rem 1rem; }
h1 { color: #fff; margin-top: 0; }
.search { display: flex; gap: 0.5rem; }
.search input { flex: 1; padding: 0.75rem; font-size: 1rem; border: none; border-radius: 8px; }
.search button { padding: 0.75rem 1.25rem; font-size: 1rem; border: none; border-radius: 8px; background: #10223d; color: #fff; cursor: pointer; }
.search button:disabled { opacity: 0.6; cursor: wait; }
.status { min-height: 1.5rem; color: #fff; }
.status.error { background: #ffe1e1; color: #8a1010; padding: 0.5rem 0.75rem; border-radius: 8px; }
.current, .day-card { background: rgba(255, 255, 255, 0.9); border-radius: 12px; padding: 1.25rem; }
.current-temp { font-size: 3rem; font-weight: 700; margin: 0.25rem 0; }
.icon { font-size: 2.5rem; margin: 0; }
.forecast-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 0.75rem; }
.day-card { text-align: center; }
.day-card h3 { margin: 0 0 0.5rem; font-size: 1rem; }
.credit, .credit a { color: #fff; font-size: 0.85rem; }
.visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }

This gives a blue background, a white card for the current weather, and a responsive grid of forecast cards that wraps on small screens.

Step 2: Weather codes and the API module

The Forecast API does not send words like "Rain". It sends a number called a weather code (the international WMO standard). We need a lookup table to turn it into text and an emoji.

// js/weather-codes.js
const WEATHER_CODES = {
  0: ['Clear sky', '☀️'],
  1: ['Mainly clear', '🌤️'],
  2: ['Partly cloudy', '⛅'],
  3: ['Overcast', '☁️'],
  45: ['Fog', '🌫️'],
  48: ['Icy fog', '🌫️'],
  51: ['Light drizzle', '🌦️'],
  53: ['Drizzle', '🌦️'],
  55: ['Heavy drizzle', '🌧️'],
  56: ['Freezing drizzle', '🌧️'],
  57: ['Freezing drizzle', '🌧️'],
  61: ['Light rain', '🌦️'],
  63: ['Rain', '🌧️'],
  65: ['Heavy rain', '🌧️'],
  66: ['Freezing rain', '🌧️'],
  67: ['Freezing rain', '🌧️'],
  71: ['Light snow', '🌨️'],
  73: ['Snow', '🌨️'],
  75: ['Heavy snow', '❄️'],
  77: ['Snow grains', '🌨️'],
  80: ['Rain showers', '🌦️'],
  81: ['Rain showers', '🌧️'],
  82: ['Violent rain showers', '⛈️'],
  85: ['Snow showers', '🌨️'],
  86: ['Heavy snow showers', '❄️'],
  95: ['Thunderstorm', '⛈️'],
  96: ['Thunderstorm with hail', '⛈️'],
  99: ['Thunderstorm with heavy hail', '⛈️'],
};

export function getWeatherInfo(code) {
  const [text, emoji] = WEATHER_CODES[code] ?? ['Unknown', '❔'];
  return { text, emoji };
}

getWeatherInfo(63) returns { text: 'Rain', emoji: '🌧️' }. The ?? gives a safe fallback if the API ever sends a code we don't know.

Now the API module. It has two async functions, one per API.

// js/api.js
const GEOCODING_URL = 'https://geocoding-api.open-meteo.com/v1/search';
const FORECAST_URL = 'https://api.open-meteo.com/v1/forecast';

export async function findCity(name) {
  const params = new URLSearchParams({ name, count: 1, language: 'en', format: 'json' });
  const response = await fetch(`${GEOCODING_URL}?${params}`);
  if (!response.ok) {
    throw new Error(`City search failed (status ${response.status}).`);
  }

  const data = await response.json();
  const place = data.results?.[0];   // no "results" when nothing matches
  if (!place) {
    throw new Error(`We couldn't find "${name}". Check the spelling and try again.`);
  }

  return {
    name: place.name,
    country: place.country ?? '',
    latitude: place.latitude,
    longitude: place.longitude,
  };
}

export async function getForecast(latitude, longitude) {
  const params = new URLSearchParams({
    latitude,
    longitude,
    current: 'temperature_2m,weather_code,wind_speed_10m',
    daily: 'temperature_2m_max,temperature_2m_min,weather_code',
    timezone: 'auto',
    forecast_days: 5,
  });
  const response = await fetch(`${FORECAST_URL}?${params}`);
  if (!response.ok) {
    throw new Error(`Weather data failed to load (status ${response.status}).`);
  }
  return response.json();
}

URLSearchParams builds the query string for us and safely encodes city names with spaces or accents, like "São Paulo". When the geocoding API finds nothing, the JSON has no results key, so data.results?.[0] gives undefined and we throw a friendly error. forecast_days: 5 asks for exactly five days. timezone: 'auto' makes the days match the city's local time.

The forecast JSON looks like this (shortened):

{
  "current": { "temperature_2m": 21.4, "weather_code": 2, "wind_speed_10m": 11.2 },
  "current_units": { "temperature_2m": "°C", "wind_speed_10m": "km/h" },
  "daily": {
    "time": ["2026-10-01", "2026-10-02", ...],
    "temperature_2m_max": [24.1, 22.8, ...],
    "temperature_2m_min": [15.3, 14.9, ...],
    "weather_code": [2, 61, ...]
  }
}

Notice that daily is a set of parallel arrays: index 0 in each array is today, index 1 is tomorrow, and so on.

Step 3: Building the page with DOM methods

The UI module creates all the elements with createElement and textContent, which is safe and clear (as you saw in the security lesson).

// js/ui.js
import { getWeatherInfo } from './weather-codes.js';

const statusEl = document.querySelector('#status');
const currentEl = document.querySelector('#current');
const forecastEl = document.querySelector('#forecast');
const cardsEl = document.querySelector('#forecast-cards');
const searchButton = document.querySelector('#search-button');

// Small helper: create an element with an optional class and text
function createEl(tag, className = '', text = '') {
  const el = document.createElement(tag);
  if (className) el.className = className;
  if (text) el.textContent = text;
  return el;
}

export function showStatus(message, isError = false) {
  statusEl.textContent = message;
  statusEl.classList.toggle('error', isError);
}

export function clearStatus() {
  showStatus('');
}

export function setLoading(isLoading) {
  searchButton.disabled = isLoading;
  searchButton.textContent = isLoading ? 'Loading...' : 'Search';
}

export function renderCurrent(place, current, units) {
  const { text, emoji } = getWeatherInfo(current.weather_code);

  const title = createEl('h2', '', place.country ? `${place.name}, ${place.country}` : place.name);
  const icon = createEl('p', 'icon', emoji);
  icon.setAttribute('aria-hidden', 'true');   // the text below describes it
  const temp = createEl('p', 'current-temp', `${Math.round(current.temperature_2m)}${units.temperature_2m}`);
  const details = createEl('p', '', `${text} · Wind ${Math.round(current.wind_speed_10m)} ${units.wind_speed_10m}`);

  currentEl.replaceChildren(title, icon, temp, details);
  currentEl.hidden = false;
}

function formatDay(dateString, index) {
  if (index === 0) return 'Today';
  // Add a time so the date is read in local time, not UTC
  const date = new Date(`${dateString}T12:00`);
  return date.toLocaleDateString('en', { weekday: 'short', day: 'numeric' });
}

export function renderForecast(daily) {
  const cards = daily.time.map((dateString, i) => {
    const { text, emoji } = getWeatherInfo(daily.weather_code[i]);
    const max = Math.round(daily.temperature_2m_max[i]);
    const min = Math.round(daily.temperature_2m_min[i]);

    const card = createEl('article', 'day-card');
    const icon = createEl('p', 'icon', emoji);
    icon.setAttribute('aria-hidden', 'true');
    card.append(
      createEl('h3', '', formatDay(dateString, i)),
      icon,
      createEl('p', '', text),
      createEl('p', '', `${max}° / ${min}°`),
    );
    return card;
  });

  cardsEl.replaceChildren(...cards);   // swap all cards in one update
  forecastEl.hidden = false;
}

Here is what the important parts do:

  • createEl() saves us from repeating three lines for every element.
  • renderCurrent() builds the heading, emoji, big temperature and details, then uses replaceChildren() to swap out the old content in one step.
  • formatDay() adds T12:00 to the date. Without it, new Date('2026-10-02') is read as midnight UTC, which can show the wrong day for users west of London.
  • renderForecast() uses the index i to read the same day from each parallel array, and builds one card per day.

Step 4: Connecting everything in main.js

// js/main.js
import { findCity, getForecast } from './api.js';
import { showStatus, clearStatus, setLoading, renderCurrent, renderForecast } from './ui.js';

const STORAGE_KEY = 'weatherApp:lastCity';
const DEFAULT_CITY = 'London';

const form = document.querySelector('#search-form');
const input = document.querySelector('#city-input');

async function loadWeather(cityName) {
  setLoading(true);
  showStatus(`Loading weather for ${cityName}...`);

  try {
    const place = await findCity(cityName);                            // 1. name -> coordinates
    const weather = await getForecast(place.latitude, place.longitude); // 2. coordinates -> weather

    renderCurrent(place, weather.current, weather.current_units);
    renderForecast(weather.daily);
    clearStatus();

    localStorage.setItem(STORAGE_KEY, place.name);   // remember for next visit
  } catch (error) {
    // fetch throws a TypeError when the network is down
    const message = error instanceof TypeError
      ? 'Network problem. Check your connection and try again.'
      : error.message;
    showStatus(message, true);
  } finally {
    setLoading(false);   // runs after success OR error
  }
}

form.addEventListener('submit', (event) => {
  event.preventDefault();
  const city = input.value.trim();
  if (city) loadWeather(city);
});

// Start: load the last city, or the default one
const startCity = localStorage.getItem(STORAGE_KEY) ?? DEFAULT_CITY;
input.value = startCity;
loadWeather(startCity);

loadWeather() is the heart of the app. It shows the loading state, then calls the two APIs in order, because the forecast needs the coordinates from the first call. If anything goes wrong, the catch block shows a friendly message. finally always turns the button back on. We save place.name (the API's spelling) so the next visit loads the right city.

Open the page with your local server. You should see London's weather and five cards. Search "Tokyo", reload the page, and Tokyo appears again. Search "xyzabc" and you get a red error message instead of a crash.

Tip: To test the loading and error states, open DevTools, go to the Network tab, and change "No throttling" to "Slow 4G" or "Offline". This is how professionals check that an app behaves well on bad connections.

Optional: search as you type with debounce

If you want the app to search automatically while typing, add a debounced input listener to main.js, using the debounce() function from the performance lesson.

function debounce(fn, delay) {
  let timerId;
  return (...args) => {
    clearTimeout(timerId);
    timerId = setTimeout(() => fn(...args), delay);
  };
}

const autoSearch = debounce((city) => {
  if (city.length >= 3) loadWeather(city);   // skip very short input
}, 600);

input.addEventListener('input', () => autoSearch(input.value.trim()));

Now the app waits until you stop typing for 600 ms before it calls the APIs, so typing "Barcelona" sends one search, not nine.

Practice exercise

Improve your weather app with one or more of these features:

  • A "Use my location" button that uses the Geolocation API and calls getForecast() with the user's coordinates (show "Your location" as the name).
  • A °C / °F switch. The Forecast API accepts temperature_unit: 'fahrenheit' as an extra parameter.
  • A list of the last 3 searched cities, saved in localStorage as JSON, shown as buttons.

Hint: for the recent cities, use JSON.stringify() to save the array and JSON.parse() to read it, and remove duplicates with [...new Set(cities)].

What's next after this JavaScript course?

Congratulations! You have gone from your first console.log() to a real, modular app that talks to live APIs. Here are good next steps:

  • Build more projects. This is the most important step. Try a to-do app with filters, a quiz game, a budget tracker or a movie search using another free API. Every project teaches you something new.
  • Learn a framework. When you feel ready, try React, Vue or Svelte. You will recognize the ideas: components are functions, state is data, and events are events.
  • Try Node.js on the server. With Node.js you can build your own API, keep secret keys safe, and work with databases, all in the JavaScript you already know.
  • Consider TypeScript. It adds types to JavaScript and catches many bugs before you run your code. Many teams use it.
  • Keep reading documentation. MDN Web Docs is the best reference for JavaScript and browser APIs.

Keep coding a little every day. Small steps add up fast.

Key takeaways

  • Real apps often chain API calls: here, a city name becomes coordinates, and coordinates become weather.
  • async/await with try...catch...finally gives you clean loading, error and success states.
  • Splitting code into ES modules (api, ui, data, main) keeps each file small and focused.
  • Build the page with DOM methods and textContent, and use replaceChildren() to update in one step.
  • localStorage makes small things like "remember my last city" easy and pleasant for users.