Back to blog
Frontend Engineeringbeginner

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.

Asma HafeezApril 17, 20267 min read
javascriptprojectfetchapilocalstorage
Share:𝕏

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

HTML
<!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

CSS
/* 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

JavaScript
// ─────────────────────────────────────────────
// 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

  1. Go to openweathermap.org and create a free account
  2. Go to "My API Keys" and copy your key
  3. Replace "YOUR_API_KEY_HERE" in app.js
  4. 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

  1. Add temperature unit toggle (°C / °F)
  2. Add geolocation to detect user's city automatically
  3. Add hourly forecast chart with Canvas or a library
  4. Store a list of favourite cities
  5. 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?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.