The plan: Type the name of any city in the world, and see its current weather: temperature, a description with an emoji, what it feels like, humidity and wind speed. The app remembers the last city you checked.
This project uses real, live data from the internet, thanks to Open-Meteo, a free weather service that doesn't require you to sign up or get an API key for personal, non-commercial projects like this one.
How it works: Weather services need a latitude and longitude, not a city name. So getting the weather is a two-step process:
- Ask Open-Meteo's geocoding API to turn the city name into coordinates.
- Ask Open-Meteo's forecast API for the current weather at those coordinates.
The data: the geocoding API sends back a list of matching places, like this (simplified):
{
"results": [
{ "name": "Paris", "latitude": 5.55602, "longitude": -0.1969, "country": "France", "admin1": "Greater London" }
]
}
And the forecast API sends back the current conditions:
{
"current": {
"temperature_2m": 27.6,
"apparent_temperature": 31.2,
"relative_humidity_2m": 82,
"wind_speed_10m": 13.4,
"weather_code": 2
}
}
(temperature_2m means "the temperature two meters above the ground," which is the standard height for weather measurements. apparent_temperature is the "feels like" temperature. And weather_code is a number that describes the weather, using an international standard.)
The HTML
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sky Check</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="app">
<h1>🌤️ Sky Check</h1>
<form id="search-form">
<input id="city-input" type="text" placeholder="Enter a city, e.g. Paris" autocomplete="off" />
<button id="search-btn" type="submit">Check</button>
</form>
<p id="status"></p>
<article id="weather" class="card hidden">
<h2 id="place"></h2>
<div id="emoji" class="emoji"></div>
<div id="temp" class="temp"></div>
<p id="description" class="description"></p>
<div class="details">
<div><span>Feels like</span><strong id="feels"></strong></div>
<div><span>Humidity</span><strong id="humidity"></strong></div>
<div><span>Wind</span><strong id="wind"></strong></div>
</div>
</article>
<p class="credit">Weather data from Open-Meteo.com</p>
</main>
<script src="script.js"></script>
</body>
</html>
The CSS
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
font-family: system-ui, sans-serif;
background: linear-gradient(180deg, #7ec8f8, #dff3ff);
display: flex;
justify-content: center;
padding: 40px 16px;
color: #22304a;
}
.app { width: 100%; max-width: 440px; text-align: center; }
h1 { margin-top: 0; }
form { display: flex; gap: 8px; }
input {
flex: 1;
padding: 12px 14px;
font-size: 16px;
border: none;
border-radius: 14px;
}
button {
padding: 12px 18px;
font-size: 16px;
border: none;
border-radius: 14px;
background: #22304a;
color: white;
cursor: pointer;
}
button:disabled { opacity: 0.5; }
#status { min-height: 24px; font-weight: bold; }
.card {
background: rgba(255, 255, 255, 0.85);
border-radius: 24px;
padding: 24px;
box-shadow: 0 10px 30px rgba(34, 48, 74, 0.15);
}
.hidden { display: none; }
.emoji { font-size: 72px; line-height: 1.1; }
.temp { font-size: 56px; font-weight: 800; }
.description { font-size: 20px; margin: 4px 0 16px; }
.details { display: flex; justify-content: space-around; gap: 8px; }
.details span { display: block; font-size: 13px; color: #6b7280; }
.credit { font-size: 12px; color: #4b5563; margin-top: 24px; }
The JavaScript
Part 1: Setup
// ---------- Elements and settings ----------
const form = document.querySelector("#search-form");
const cityInput = document.querySelector("#city-input");
const searchButton = document.querySelector("#search-btn");
const status = document.querySelector("#status");
const card = document.querySelector("#weather");
const LAST_CITY_KEY = "sky-check-last-city";
Part 2: Talking to the APIs
// ---------- Talking to the Open-Meteo APIs ----------
// Step 1: turn a city name into a place with a latitude and longitude
async function findPlace(cityName) {
const url = `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(cityName)}&count=1`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`The place search failed (error ${response.status}).`);
}
const data = await response.json();
const place = data.results?.[0];
if (!place) {
throw new Error(`Couldn't find anywhere called "${cityName}". Check the spelling?`);
}
return place;
}
// Step 2: get the current weather at that latitude and longitude
async function getCurrentWeather(latitude, longitude) {
const fields = "temperature_2m,apparent_temperature,relative_humidity_2m,wind_speed_10m,weather_code";
const url = `https://api.open-meteo.com/v1/forecast?latitude=${latitude}&longitude=${longitude}¤t=${fields}`;
const response = await fetch(url);
if (!response.ok) {
throw new Error(`The weather service failed (error ${response.status}).`);
}
const data = await response.json();
return data.current;
}
These two async functions (Freshman year) each do one job: make a request, check response.ok (because fetch doesn't reject on errors like 404!), turn the JSON into an object, and return the useful part.
A few details worth noticing:
encodeURIComponent(cityName)makes the city name safe to put in a URL. Spaces and special characters (like in "Cape Town" or "São Paulo") have to be encoded in a special way inside web addresses, and this built-in function does it for you.data.results?.[0]uses optional chaining (Grade 13). If nothing matches the name, the API doesn't includeresultsat all, sodata.resultsisundefined. Without the?., reading[0]fromundefinedwould crash. With it,placeis justundefined, and we throw a friendly error instead.- Throwing an
Errorwith a human-friendly message (Summer School) means we can show that message straight to the user later.
Part 3: Describing the weather
// ---------- Turning weather codes into words and emoji ----------
function describeWeather(code) {
if (code === 0) return { emoji: "☀️", text: "Clear sky" };
if (code <= 2) return { emoji: "🌤️", text: "Partly cloudy" };
if (code === 3) return { emoji: "☁️", text: "Overcast" };
if (code === 45 || code === 48) return { emoji: "🌫️", text: "Foggy" };
if (code >= 51 && code <= 57) return { emoji: "🌦️", text: "Drizzle" };
if (code >= 61 && code <= 67) return { emoji: "🌧️", text: "Rain" };
if (code >= 71 && code <= 77) return { emoji: "❄️", text: "Snow" };
if (code >= 80 && code <= 82) return { emoji: "🌧️", text: "Rain showers" };
if (code >= 85 && code <= 86) return { emoji: "🌨️", text: "Snow showers" };
if (code >= 95) return { emoji: "⛈️", text: "Thunderstorm" };
return { emoji: "🌈", text: "Mysterious weather" };
}
Weather codes follow an international standard from the World Meteorological Organization: 0 is a clear sky, 61 to 67 are types of rain, 95 and up are thunderstorms, and so on. This function turns a code into an emoji and a short description, using guard-clause style if statements that return as soon as one matches (Grade 6).
Part 4: Showing the results
// ---------- Showing it on the page ----------
function showWeather(place, weather) {
const { emoji, text } = describeWeather(weather.weather_code);
const region = [place.admin1, place.country].filter(Boolean).join(", ");
document.querySelector("#place").textContent = `${place.name}${region ? ", " + region : ""}`;
document.querySelector("#emoji").textContent = emoji;
document.querySelector("#temp").textContent = `${Math.round(weather.temperature_2m)}°C`;
document.querySelector("#description").textContent = text;
document.querySelector("#feels").textContent = `${Math.round(weather.apparent_temperature)}°C`;
document.querySelector("#humidity").textContent = `${weather.relative_humidity_2m}%`;
document.querySelector("#wind").textContent = `${Math.round(weather.wind_speed_10m)} km/h`;
card.classList.remove("hidden");
}
async function checkWeather(cityName) {
status.textContent = "⏳ Checking the sky...";
searchButton.disabled = true;
card.classList.add("hidden");
try {
const place = await findPlace(cityName);
const weather = await getCurrentWeather(place.latitude, place.longitude);
showWeather(place, weather);
status.textContent = "";
localStorage.setItem(LAST_CITY_KEY, cityName);
} catch (error) {
status.textContent = `😕 ${error.message}`;
} finally {
searchButton.disabled = false;
}
}
showWeather fills in all the elements of the weather card. One neat line: [place.admin1, place.country].filter(Boolean).join(", "). Some places don't have a region (admin1), so we put the region and country in an array, use filter(Boolean) to throw away any that are missing (Boolean is a function that returns false for falsy values like undefined), and join what's left with a comma.
checkWeather is the heart of the app, and it follows the loading → success or error pattern from Freshman year:
- Show a loading message, disable the button (so impatient users can't click it fifty times), and hide any old weather.
trythe two API calls, one after the other (we need the coordinates before we can ask for the weather, soPromise.allwouldn't work here).- If it worked, show the weather and save the city name.
- If anything failed,
catchthe error and show its message. finally, whatever happened, re-enable the button.
Part 5: Events
// ---------- Events ----------
form.addEventListener("submit", (event) => {
event.preventDefault();
const cityName = cityInput.value.trim();
if (!cityName) {
status.textContent = "Please type a city name first. 🙂";
return;
}
checkWeather(cityName);
});
// When the page opens, show the last city the user looked up
const lastCity = localStorage.getItem(LAST_CITY_KEY);
if (lastCity) {
cityInput.value = lastCity;
checkWeather(lastCity);
}
The form's submit event checks the input and calls checkWeather. And when the page first opens, if there's a city saved from last time, we put it in the input box and look it up straight away.
Open the app, type your town, and press Enter. Real, live weather from the internet, in an app you built. Try a few cities around the world: Tokyo, London, Reykjavík (brrr), Timbuktu. Try a city that doesn't exist, like "Atlantis," and see your friendly error message. Turn off your Wi-Fi and try again to see what happens then.
- Use my location: add a "📍 Use my location" button that uses the browser's
navigator.geolocation.getCurrentPosition()to get the user's coordinates and skip the city search. (Look it up on MDN!) - Fahrenheit toggle: add a button that switches between °C and °F. (You wrote the conversion function back in Grade 3!)
- 5-day forecast: Open-Meteo can also send daily forecasts. Read its documentation and add
&daily=temperature_2m_max,temperature_2m_min,weather_code&timezone=autoto the URL, then show the next five days as a row of little cards. - Favorites: let users save a list of favorite cities (in
localStorage) and show buttons to check them with one click. - Day and night: change the background gradient depending on the weather, or whether it's day or night (the API can tell you with
is_day).