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.
- A search form that takes a city name and looks up its coordinates with
fetch. - A second
fetchcall 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 aResponseobject. - Promises and
async/await— declaring anasync functionand usingawaitinside it. - Error handling —
try/catchblocks, and throwing your ownErrorobjects. - 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 forecastconst 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 labelsconst 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 endpointasync 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 locationasync function getWeather(latitude, longitude) { const url = FORECAST_URL + '?latitude=' + latitude + '&longitude=' + longitude + '¤t_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}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 oncefunction 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 itfunction 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 + '¤t_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); }});Sample Run
Click Run to see what this code prints.
Extend This Project
- Add a 5-day forecast section using Open-Meteo's
dailyparameters alongsidecurrent_weather. - Use
navigator.geolocation.getCurrentPositionto 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
localStorageand 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.