LearnAI ToolsCareerPractice BuildsPlayDevelopmentContact
JavaScriptIntermediate~2.5 hours

Weather Dashboard

Fetch and display live weather data from a public API with loading and error states.

Fetch APIAsync/AwaitDOM

Overview

A weather dashboard is the standard project for practicing the Fetch API because it forces you to deal with everything a real network request involves: the request takes time, so the UI needs a loading state; it can fail (a typo in a city name, a dropped connection), so the UI needs an error state; and the response has to be parsed and mapped onto specific DOM elements once it succeeds. async/await is what keeps this logic readable instead of turning into a chain of nested .then() calls.

This tutorial calls Open-Meteo, a free weather API that needs no API key or account. Getting a forecast is actually two requests: first a geocoding request that turns a typed city name into latitude/longitude, then a forecast request that turns those coordinates into current weather conditions. Both are shown here exactly as you would write them against the real, public API.

What You'll Build
  • A search form that takes a city name and looks up its coordinates with fetch.
  • A second fetch call that turns those coordinates into live current-weather data.
  • A loading state shown the instant a search starts, before either request finishes.
  • An error state shown if the city can't be found or a request fails.
  • A results panel that renders temperature, conditions, and wind speed once data arrives.

Prerequisites

  • The Fetch API — calling fetch(url) and reading a Response object.
  • Promises and async/await — declaring an async function and using await inside it.
  • Error handling — try/catch blocks, and throwing your own Error objects.
  • DOM basics — selecting elements and toggling classes to show/hide sections.
  • Basic objects and property access, for reading fields off a parsed JSON response.

Project Structure

The page has three panels that are never visible at the same time: a loading message, an error message, and the results panel — a setView() helper centralizes which one is showing so no code path can accidentally leave two panels visible together. The HTML and CSS below are introduced once and do not change again; every step after this one only edits script.js.

The data flow for a single search is: read the typed city name, switch to the loading view, await the geocoding request, await the forecast request using the coordinates from the first request, then switch to the results view and fill in the DOM. If either request fails, a single catch block switches to the error view instead.

<!-- Root container for the weather dashboard -->
<div class="weather-app">
<h1>Weather Dashboard</h1>
<form id="search-form">
<input type="text" id="city-input" placeholder="Enter a city name" autocomplete="off" />
<button type="submit">Search</button>
</form>
<!-- Shown only while a request is in flight -->
<p id="loading" class="hidden">Loading weather...</p>
<!-- Shown only if a request fails or the city can't be found -->
<p id="error" class="hidden"></p>
<!-- Shown only once a search succeeds; text content is filled in by JS -->
<div id="result" class="hidden">
<h2 id="result-city"></h2>
<p id="result-temp"></p>
<p id="result-desc"></p>
<p id="result-wind"></p>
</div>
</div>

The three panels (#loading, #error, #result) all start with the hidden class, and every one of the five result fields starts empty — JavaScript is entirely responsible for filling them in and deciding which panel is visible at any moment.

* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: Arial, Helvetica, sans-serif;
background: #1a1d23;
color: #e8e8e8;
display: flex;
justify-content: center;
padding: 40px 16px;
}
.weather-app {
width: 100%;
max-width: 420px;
background: #23262e;
border-radius: 10px;
padding: 24px;
}
.weather-app h1 {
margin-bottom: 16px;
font-size: 1.5rem;
}
#search-form {
display: flex;
gap: 8px;
margin-bottom: 20px;
}
#city-input {
flex: 1; /* Input grows to fill the space; the button keeps its natural width */
padding: 10px 12px;
border-radius: 6px;
border: 1px solid #3a3f4b;
background: #1a1d23;
color: #e8e8e8;
font-size: 1rem;
}
#search-form button {
padding: 10px 16px;
border: none;
border-radius: 6px;
background: #4f8cff;
color: white;
cursor: pointer;
font-weight: bold;
}
#loading {
text-align: center;
color: #9aa4b2;
}
#error {
text-align: center;
color: #ff6b6b;
background: rgba(255, 107, 107, 0.1);
border-radius: 6px;
padding: 12px;
}
#result {
text-align: center;
background: #1a1d23;
border-radius: 8px;
padding: 20px;
}
#result-city {
font-size: 1.3rem;
margin-bottom: 8px;
}
#result-temp {
font-size: 2.5rem;
font-weight: bold;
margin-bottom: 4px;
}
#result-desc {
color: #9aa4b2;
margin-bottom: 8px;
}
.hidden {
display: none; /* Toggled from JS instead of removing/re-adding the element */
}

Step 1: Select Elements and Define Config

Alongside the usual element references, this step defines the two API endpoints and a lookup table that translates Open-Meteo's numeric weather codes into readable text — the forecast API returns a weathercode integer, not a description string.

const searchForm = document.getElementById('search-form');
const cityInput = document.getElementById('city-input');
const loadingEl = document.getElementById('loading');
const errorEl = document.getElementById('error');
const resultEl = document.getElementById('result');
const resultCity = document.getElementById('result-city');
const resultTemp = document.getElementById('result-temp');
const resultDesc = document.getElementById('result-desc');
const resultWind = document.getElementById('result-wind');
// Open-Meteo needs no API key, which makes it a good teaching example — one
// endpoint turns a city name into coordinates, another turns coordinates
// into a live forecast
const GEOCODE_URL = 'https://geocoding-api.open-meteo.com/v1/search';
const FORECAST_URL = 'https://api.open-meteo.com/v1/forecast';
// The forecast API returns a numeric weather code instead of a text
// description; this table translates the codes we care about into labels
const WEATHER_CODES = {
0: 'Clear sky',
1: 'Mainly clear',
2: 'Partly cloudy',
3: 'Overcast',
45: 'Fog',
48: 'Depositing rime fog',
51: 'Light drizzle',
61: 'Slight rain',
63: 'Moderate rain',
65: 'Heavy rain',
71: 'Slight snow fall',
80: 'Rain showers',
95: 'Thunderstorm',
};

Step 2: Fetch Coordinates for a City

Declaring a function async lets you use await inside it, which pauses just that function (not the whole page) until the promise it is awaiting settles. fetch only rejects on a genuine network failure — an HTTP error status like 404 still resolves successfully, so response.ok has to be checked by hand.

// Turns a typed city name into latitude/longitude using Open-Meteo's free
// geocoding endpoint
async function getCoordinates(city) {
const url = GEOCODE_URL + '?name=' + encodeURIComponent(city) + '&count=1'; // encodeURIComponent safely escapes spaces/punctuation in the city name
const response = await fetch(url); // Pauses this function until the network responds
if (!response.ok) {
// fetch resolves even for a 4xx/5xx response, so a bad status has to be
// turned into a thrown error manually
throw new Error('Could not reach the geocoding service.');
}
const data = await response.json(); // Parses the response body as JSON; this is also asynchronous
if (!data.results || data.results.length === 0) {
throw new Error('No city found with that name.'); // Open-Meteo omits results entirely when nothing matches
}
const place = data.results[0]; // Take the best (first) match
return { latitude: place.latitude, longitude: place.longitude, name: place.name };
}

Step 3: Fetch the Current Weather

With coordinates in hand, the second request asks the forecast endpoint for current conditions at that exact location. current_weather=true is the query parameter that tells Open-Meteo to include a "right now" reading instead of only multi-day forecasts.

// Given coordinates, asks Open-Meteo for the current weather at that location
async function getWeather(latitude, longitude) {
const url =
FORECAST_URL +
'?latitude=' + latitude +
'&longitude=' + longitude +
'&current_weather=true'; // Tells the API to include a "right now" reading, not just multi-day data
const response = await fetch(url);
if (!response.ok) {
throw new Error('Could not reach the forecast service.');
}
const data = await response.json();
return data.current_weather; // Has temperature, windspeed, and weathercode fields
}
Sample console.log(weather) Output

Click Run to see what this code prints.

Step 4: Manage Loading and Error States

setView() is the one function that decides which of the three panels is visible, so the loading, error, and success paths can never disagree about what the user should be looking at.

// Centralizes exactly which of the three panels is visible, so no code
// path can accidentally leave two of them shown at once
function setView(view) {
loadingEl.classList.toggle('hidden', view !== 'loading');
errorEl.classList.toggle('hidden', view !== 'error');
resultEl.classList.toggle('hidden', view !== 'result');
}
function showError(message) {
errorEl.textContent = message;
setView('error');
}

Step 5: Render the Weather to the DOM

renderWeather takes the plain objects returned by Steps 2 and 3 and writes their fields into the result panel's text content, then reveals that panel through setView.

// Fills in the result panel from a coordinates object and a weather object,
// then reveals it
function renderWeather(place, weather) {
const description = WEATHER_CODES[weather.weathercode] || 'Unknown conditions'; // Fall back gracefully for any code not in our table
resultCity.textContent = place.name;
resultTemp.textContent = Math.round(weather.temperature) + '\u00b0C';
resultDesc.textContent = description;
resultWind.textContent = 'Wind: ' + weather.windspeed + ' km/h';
setView('result');
}

Step 6: Wire Up the Search Form

The form's submit handler is itself declared async so it can await both requests in sequence, one right after the other, with a single try/catch around the whole thing to handle a failure from either step.

searchForm.addEventListener('submit', async function (event) {
event.preventDefault();
const city = cityInput.value.trim();
if (city === '') return;
setView('loading'); // Give immediate feedback before either network call even starts
try {
const place = await getCoordinates(city); // First request: name -> coordinates
const weather = await getWeather(place.latitude, place.longitude); // Second request: coordinates -> weather
renderWeather(place, weather);
} catch (err) {
// Both getCoordinates and getWeather can throw; one catch block handles either failure
showError(err.message);
}
});

Complete Code

Here is the complete project — the HTML shell, the CSS, and the fully assembled JavaScript, using the real Open-Meteo endpoints exactly as you would call them from a live page.

<!-- Root container for the weather dashboard -->
<div class="weather-app">
<h1>Weather Dashboard</h1>
<form id="search-form">
<input type="text" id="city-input" placeholder="Enter a city name" autocomplete="off" />
<button type="submit">Search</button>
</form>
<p id="loading" class="hidden">Loading weather...</p>
<p id="error" class="hidden"></p>
<div id="result" class="hidden">
<h2 id="result-city"></h2>
<p id="result-temp"></p>
<p id="result-desc"></p>
<p id="result-wind"></p>
</div>
</div>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: Arial, Helvetica, sans-serif;
background: #1a1d23;
color: #e8e8e8;
display: flex;
justify-content: center;
padding: 40px 16px;
}
.weather-app {
width: 100%;
max-width: 420px;
background: #23262e;
border-radius: 10px;
padding: 24px;
}
.weather-app h1 {
margin-bottom: 16px;
font-size: 1.5rem;
}
#search-form {
display: flex;
gap: 8px;
margin-bottom: 20px;
}
#city-input {
flex: 1;
padding: 10px 12px;
border-radius: 6px;
border: 1px solid #3a3f4b;
background: #1a1d23;
color: #e8e8e8;
font-size: 1rem;
}
#search-form button {
padding: 10px 16px;
border: none;
border-radius: 6px;
background: #4f8cff;
color: white;
cursor: pointer;
font-weight: bold;
}
#loading {
text-align: center;
color: #9aa4b2;
}
#error {
text-align: center;
color: #ff6b6b;
background: rgba(255, 107, 107, 0.1);
border-radius: 6px;
padding: 12px;
}
#result {
text-align: center;
background: #1a1d23;
border-radius: 8px;
padding: 20px;
}
#result-city {
font-size: 1.3rem;
margin-bottom: 8px;
}
#result-temp {
font-size: 2.5rem;
font-weight: bold;
margin-bottom: 4px;
}
#result-desc {
color: #9aa4b2;
margin-bottom: 8px;
}
.hidden {
display: none;
}
const searchForm = document.getElementById('search-form');
const cityInput = document.getElementById('city-input');
const loadingEl = document.getElementById('loading');
const errorEl = document.getElementById('error');
const resultEl = document.getElementById('result');
const resultCity = document.getElementById('result-city');
const resultTemp = document.getElementById('result-temp');
const resultDesc = document.getElementById('result-desc');
const resultWind = document.getElementById('result-wind');
const GEOCODE_URL = 'https://geocoding-api.open-meteo.com/v1/search';
const FORECAST_URL = 'https://api.open-meteo.com/v1/forecast';
const WEATHER_CODES = {
0: 'Clear sky',
1: 'Mainly clear',
2: 'Partly cloudy',
3: 'Overcast',
45: 'Fog',
48: 'Depositing rime fog',
51: 'Light drizzle',
61: 'Slight rain',
63: 'Moderate rain',
65: 'Heavy rain',
71: 'Slight snow fall',
80: 'Rain showers',
95: 'Thunderstorm',
};
async function getCoordinates(city) {
const url = GEOCODE_URL + '?name=' + encodeURIComponent(city) + '&count=1';
const response = await fetch(url);
if (!response.ok) {
throw new Error('Could not reach the geocoding service.');
}
const data = await response.json();
if (!data.results || data.results.length === 0) {
throw new Error('No city found with that name.');
}
const place = data.results[0];
return { latitude: place.latitude, longitude: place.longitude, name: place.name };
}
async function getWeather(latitude, longitude) {
const url =
FORECAST_URL +
'?latitude=' + latitude +
'&longitude=' + longitude +
'&current_weather=true';
const response = await fetch(url);
if (!response.ok) {
throw new Error('Could not reach the forecast service.');
}
const data = await response.json();
return data.current_weather;
}
function setView(view) {
loadingEl.classList.toggle('hidden', view !== 'loading');
errorEl.classList.toggle('hidden', view !== 'error');
resultEl.classList.toggle('hidden', view !== 'result');
}
function showError(message) {
errorEl.textContent = message;
setView('error');
}
function renderWeather(place, weather) {
const description = WEATHER_CODES[weather.weathercode] || 'Unknown conditions';
resultCity.textContent = place.name;
resultTemp.textContent = Math.round(weather.temperature) + '\u00b0C';
resultDesc.textContent = description;
resultWind.textContent = 'Wind: ' + weather.windspeed + ' km/h';
setView('result');
}
searchForm.addEventListener('submit', async function (event) {
event.preventDefault();
const city = cityInput.value.trim();
if (city === '') return;
setView('loading');
try {
const place = await getCoordinates(city);
const weather = await getWeather(place.latitude, place.longitude);
renderWeather(place, weather);
} catch (err) {
showError(err.message);
}
});
Live Preview

Sample Run

Sample Interaction

Click Run to see what this code prints.

Extend This Project

  • Add a 5-day forecast section using Open-Meteo's daily parameters alongside current_weather.
  • Use navigator.geolocation.getCurrentPosition to auto-fill the dashboard with the user's current location on load.
  • Add a Celsius/Fahrenheit toggle that converts and re-renders the already-fetched data without a new request.
  • Save the last few searched cities to localStorage and show them as quick-access buttons.
  • Swap the plain text weather description for a matching icon or emoji based on the weathercode.

Summary

You built a real weather dashboard using the Fetch API and async/await to chain two live network requests, with proper loading and error states along the way. The pattern here — try { await request1(); await request2(); render(); } catch { showError(); } — is the standard shape of almost any JavaScript feature that talks to a real API, well beyond weather data.