LearnAI ToolsCareerPractice BuildsPlayContact
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.