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 `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 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 `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.