Build a Weather App — JavaScript Project with Fetch API
Build a complete weather app from scratch using HTML, CSS, and JavaScript. Fetch real data from OpenWeatherMap, show forecasts, handle errors, and save last city to localStorage.
Build a Weather App — JavaScript Project
This project puts together everything from the JS beginner series: DOM manipulation, Fetch API, async/await, error handling, and localStorage. You'll build a working weather app that fetches real data.
What We're Building
- Search by city name
- Current weather (temperature, humidity, wind, condition)
- 5-day forecast
- Error handling for invalid cities
- Loading spinner
- Save last searched city to localStorage
Step 1: HTML Structure
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Weather App</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="app">
<header>
<h1>Weather</h1>
<form id="search-form">
<input
type="text"
id="city-input"
placeholder="Search city..."
autocomplete="off"
/>
<button type="submit">Search</button>
</form>
</header>
<div id="error-message" class="error hidden"></div>
<div id="spinner" class="spinner hidden">
<div class="spin"></div>
</div>
<section id="current-weather" class="hidden">
<div class="city-name" id="city-name"></div>
<div class="temp" id="temperature"></div>
<div class="condition" id="condition"></div>
<div class="details">
<span id="humidity"></span>
<span id="wind"></span>
<span id="feels-like"></span>
</div>
</section>
<section id="forecast" class="hidden">
<h2>5-Day Forecast</h2>
<div class="forecast-grid" id="forecast-grid"></div>
</section>
</div>
<script src="app.js"></script>
</body>
</html>Step 2: CSS Styling
/* style.css */
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: linear-gradient(135deg, #1a1a2e, #16213e);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
color: white;
padding: 20px;
}
.app {
width: 100%;
max-width: 480px;
}
header {
text-align: center;
margin-bottom: 24px;
}
h1 {
font-size: 2rem;
margin-bottom: 16px;
letter-spacing: 2px;
}
#search-form {
display: flex;
gap: 8px;
}
#city-input {
flex: 1;
padding: 12px 16px;
border: 2px solid rgba(255,255,255,0.2);
border-radius: 12px;
background: rgba(255,255,255,0.1);
color: white;
font-size: 1rem;
outline: none;
transition: border-color 0.2s;
}
#city-input:focus { border-color: rgba(255,255,255,0.5); }
#city-input::placeholder { color: rgba(255,255,255,0.4); }
button[type="submit"] {
padding: 12px 20px;
background: #4f46e5;
color: white;
border: none;
border-radius: 12px;
cursor: pointer;
font-size: 1rem;
font-weight: 600;
transition: opacity 0.2s;
}
button[type="submit"]:hover { opacity: 0.9; }
/* Current weather */
#current-weather {
background: rgba(255,255,255,0.1);
border-radius: 20px;
padding: 32px;
text-align: center;
margin-bottom: 20px;
backdrop-filter: blur(10px);
}
.city-name { font-size: 1.5rem; font-weight: 600; margin-bottom: 8px; }
.temp { font-size: 4rem; font-weight: 700; margin: 12px 0; }
.condition { font-size: 1.1rem; color: rgba(255,255,255,0.7); margin-bottom: 20px; text-transform: capitalize; }
.details {
display: flex;
justify-content: center;
gap: 24px;
font-size: 0.9rem;
color: rgba(255,255,255,0.7);
}
/* Forecast */
#forecast h2 { margin-bottom: 12px; font-size: 1rem; opacity: 0.7; }
.forecast-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 8px;
}
.forecast-card {
background: rgba(255,255,255,0.1);
border-radius: 12px;
padding: 12px 8px;
text-align: center;
font-size: 0.85rem;
}
.forecast-card .day { font-weight: 600; margin-bottom: 4px; }
.forecast-card .fc-temp { font-size: 1.1rem; font-weight: 700; margin-top: 4px; }
/* Error */
.error {
background: rgba(239, 68, 68, 0.2);
border: 1px solid rgba(239, 68, 68, 0.4);
border-radius: 12px;
padding: 12px 16px;
text-align: center;
margin-bottom: 16px;
color: #fca5a5;
}
/* Spinner */
.spinner {
display: flex;
justify-content: center;
padding: 40px;
}
.spin {
width: 40px;
height: 40px;
border: 4px solid rgba(255,255,255,0.2);
border-top-color: white;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.hidden { display: none !important; }Step 3: JavaScript — app.js
// ─────────────────────────────────────────────
// CONFIGURATION
// Sign up free at openweathermap.org to get an API key
// ─────────────────────────────────────────────
const API_KEY = "YOUR_API_KEY_HERE";
const BASE_URL = "https://api.openweathermap.org/data/2.5";
// ─────────────────────────────────────────────
// DOM REFERENCES
// ─────────────────────────────────────────────
const form = document.getElementById("search-form");
const input = document.getElementById("city-input");
const errorEl = document.getElementById("error-message");
const spinner = document.getElementById("spinner");
const currentWeatherEl = document.getElementById("current-weather");
const forecastEl = document.getElementById("forecast");
// ─────────────────────────────────────────────
// STATE
// ─────────────────────────────────────────────
let abortController = null;
// ─────────────────────────────────────────────
// EVENT LISTENERS
// ─────────────────────────────────────────────
form.addEventListener("submit", (e) => {
e.preventDefault();
const city = input.value.trim();
if (!city) return;
loadWeather(city);
});
// Load last searched city on page load
document.addEventListener("DOMContentLoaded", () => {
const lastCity = localStorage.getItem("lastCity");
if (lastCity) {
input.value = lastCity;
loadWeather(lastCity);
}
});
// ─────────────────────────────────────────────
// MAIN FUNCTION
// ─────────────────────────────────────────────
async function loadWeather(city) {
// Cancel any in-flight request
abortController?.abort();
abortController = new AbortController();
setUIState("loading");
try {
// Fetch current weather and 5-day forecast in parallel
const [current, forecast] = await Promise.all([
fetchWeather(`${BASE_URL}/weather?q=${city}&units=metric&appid=${API_KEY}`, abortController.signal),
fetchWeather(`${BASE_URL}/forecast?q=${city}&cnt=40&units=metric&appid=${API_KEY}`, abortController.signal)
]);
renderCurrentWeather(current);
renderForecast(forecast);
setUIState("success");
// Save to localStorage
localStorage.setItem("lastCity", city);
} catch (err) {
if (err.name === "AbortError") return; // cancelled, ignore
setUIState("error", getErrorMessage(err));
}
}
// ─────────────────────────────────────────────
// API HELPER
// ─────────────────────────────────────────────
async function fetchWeather(url, signal) {
const res = await fetch(url, { signal });
if (!res.ok) {
const errorData = await res.json().catch(() => ({}));
const err = new Error(errorData.message || `HTTP ${res.status}`);
err.status = res.status;
throw err;
}
return res.json();
}
// ─────────────────────────────────────────────
// RENDER FUNCTIONS
// ─────────────────────────────────────────────
function renderCurrentWeather(data) {
document.getElementById("city-name").textContent =
`${data.name}, ${data.sys.country}`;
document.getElementById("temperature").textContent =
`${Math.round(data.main.temp)}°C`;
document.getElementById("condition").textContent =
data.weather[0].description;
document.getElementById("humidity").textContent =
`💧 ${data.main.humidity}%`;
document.getElementById("wind").textContent =
`💨 ${Math.round(data.wind.speed)} m/s`;
document.getElementById("feels-like").textContent =
`🌡 Feels ${Math.round(data.main.feels_like)}°C`;
}
function renderForecast(data) {
// API returns data every 3h — get one entry per day (noon reading)
const dailyData = getDailyForecasts(data.list);
document.getElementById("forecast-grid").innerHTML = dailyData
.map(item => {
const date = new Date(item.dt * 1000);
const day = date.toLocaleDateString("en", { weekday: "short" });
const temp = Math.round(item.main.temp);
const icon = getWeatherEmoji(item.weather[0].main);
return `
<div class="forecast-card">
<div class="day">${day}</div>
<div>${icon}</div>
<div class="fc-temp">${temp}°C</div>
</div>
`;
})
.join("");
}
// Pick one forecast per day — the closest to noon
function getDailyForecasts(list) {
const days = new Map();
for (const item of list) {
const date = new Date(item.dt * 1000);
const dayKey = date.toDateString();
const hour = date.getHours();
if (!days.has(dayKey) || Math.abs(hour - 12) < Math.abs(new Date(days.get(dayKey).dt * 1000).getHours() - 12)) {
days.set(dayKey, item);
}
}
return Array.from(days.values()).slice(0, 5);
}
function getWeatherEmoji(condition) {
const map = {
Clear: "☀️", Clouds: "☁️", Rain: "🌧️", Drizzle: "🌦️",
Thunderstorm: "⛈️", Snow: "❄️", Mist: "🌫️", Fog: "🌫️",
};
return map[condition] || "🌤️";
}
// ─────────────────────────────────────────────
// UI STATE MANAGEMENT
// ─────────────────────────────────────────────
function setUIState(state, message = "") {
spinner.classList.add("hidden");
errorEl.classList.add("hidden");
currentWeatherEl.classList.add("hidden");
forecastEl.classList.add("hidden");
if (state === "loading") {
spinner.classList.remove("hidden");
} else if (state === "success") {
currentWeatherEl.classList.remove("hidden");
forecastEl.classList.remove("hidden");
} else if (state === "error") {
errorEl.textContent = message;
errorEl.classList.remove("hidden");
}
}
function getErrorMessage(err) {
if (err.status === 404) return "City not found. Check the spelling and try again.";
if (err.status === 401) return "Invalid API key. Get a free key at openweathermap.org";
if (err.status === 429) return "Too many requests. Please wait a moment.";
return "Failed to load weather. Check your connection and try again.";
}Step 4: Getting Your API Key
- Go to openweathermap.org and create a free account
- Go to "My API Keys" and copy your key
- Replace
"YOUR_API_KEY_HERE"inapp.js - Free tier: 1,000 calls/day, enough for development
What This Project Demonstrates
- Fetch API with real external data
- Promise.all for parallel requests (current + forecast)
- AbortController to cancel stale requests
- Error handling with meaningful user messages
- localStorage for persistence across sessions
- DOM manipulation to render dynamic content
- UI state management (loading/success/error)
- CSS Grid for the forecast layout
Extensions to Try
- Add temperature unit toggle (°C / °F)
- Add geolocation to detect user's city automatically
- Add hourly forecast chart with Canvas or a library
- Store a list of favourite cities
- Add dark/light mode toggle
Key Takeaways
This project shows how a real app works: fetch data, handle all states (loading/success/error), update the UI, and persist user preferences. These exact patterns appear in every React, Vue, or plain-JS app you'll ever build.
Enjoyed this article?
Explore the Frontend Engineering learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.