What You'll Build

A weather app that fetches real data from the Open-Meteo API. Search any city, get current temperature, weather conditions, and a 5-day forecast. Includes loading spinner, error handling, and a "use my location" button.

  • Search any city by name
  • Live temperature, weather, wind speed, humidity
  • 5-day forecast with highs and lows
  • Loading spinner while fetching
  • Error messages for failed searches
  • Uses Open-Meteo API — no key required
  • Remembers your last search with localStorage
▶ Live Preview — this is what you'll build

What You'll Learn

Why this project is a milestone: Every previous project worked entirely offline. This one talks to the internet. That means it can fail in new ways — slow connections, rate limits, API outages. Learning to handle those failures is what separates hobby code from real software.

1

Build the HTML Structure

The layout has three states: loading, error, and success. All three exist in the HTML — CSS and JavaScript decide which one to show.

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>
</head>
<body>
    <div class="card">
        <h1>Weather App</h1>
        <p class="sub">Search any city worldwide</p>

        <form class="search" id="searchForm">
            <input type="text" id="cityInput" placeholder="e.g. London, Tokyo" value="London" autocomplete="off">
            <button type="submit" id="searchBtn">Search</button>
        </form>

        <div class="error" id="errorMsg"></div>

        <div class="status" id="status">
            <div class="spinner"></div>
            <div>Loading weather...</div>
        </div>

        <div class="weather" id="weather">
            <div class="location" id="location">—</div>
            <div class="date" id="date">—</div>

            <div class="current">
                <div class="icon" id="currentIcon">☀️</div>
                <div class="temp" id="currentTemp">—°</div>
                <div class="condition" id="currentCondition">—</div>

                <div class="meta">
                    <div class="meta-item">
                        <div class="lbl">Feels</div>
                        <div class="val" id="feelsLike">—°</div>
                    </div>
                    <div class="meta-item">
                        <div class="lbl">Wind</div>
                        <div class="val" id="wind">—</div>
                    </div>
                    <div class="meta-item">
                        <div class="lbl">Humidity</div>
                        <div class="val" id="humidity">—%</div>
                    </div>
                </div>
            </div>

            <div class="forecast-title">5-Day Forecast</div>
            <div class="forecast" id="forecast"></div>
        </div>
    </div>

    <script src="script.js"></script>
</body>
</html>

Key structural idea: The status, error, and weather blocks are all present in HTML. JavaScript shows one and hides the others based on the current state. This is a common pattern for async UIs.

2

Style It with CSS

CSS
* { box-sizing: border-box; margin: 0; padding: 0; }

body {
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
    background: linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%);
    padding: 20px;
    color: #0f172a;
    min-height: 100vh;
}

.card {
    max-width: 500px;
    margin: 0 auto;
    background: #fff;
    border-radius: 20px;
    padding: 24px;
    box-shadow: 0 20px 60px rgba(15,23,42,0.12);
}

h1 { font-size: 1.3rem; margin-bottom: 4px; }
.sub { color: #64748b; font-size: 0.85rem; margin-bottom: 18px; }

/* Search bar */
.search {
    display: flex;
    gap: 8px;
    margin-bottom: 18px;
}
.search input {
    flex: 1;
    padding: 12px 14px;
    border: 2px solid #e2e8f0;
    border-radius: 10px;
    font-size: 0.95rem;
    font-family: inherit;
    outline: none;
    transition: 0.2s;
}
.search input:focus {
    border-color: #3b82f6;
    box-shadow: 0 0 0 3px rgba(59,130,246,0.15);
}
.search button {
    background: #3b82f6;
    color: #fff;
    border: none;
    padding: 0 18px;
    border-radius: 10px;
    font-weight: 700;
    font-size: 0.85rem;
    cursor: pointer;
    transition: 0.2s;
    font-family: inherit;
}
.search button:hover { background: #2563eb; }
.search button:disabled {
    background: #cbd5e1;
    cursor: not-allowed;
}

/* Status / spinner */
.status {
    text-align: center;
    padding: 40px 20px;
    color: #64748b;
    font-size: 0.9rem;
}
.spinner {
    width: 32px;
    height: 32px;
    border: 3px solid #e2e8f0;
    border-top-color: #3b82f6;
    border-radius: 50%;
    margin: 0 auto 12px;
    animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }

/* Error */
.error {
    background: #fef2f2;
    border: 1px solid #fca5a5;
    color: #dc2626;
    padding: 12px;
    border-radius: 10px;
    font-size: 0.85rem;
    text-align: center;
    margin: 12px 0;
    display: none;
}
.error.show { display: block; }

/* Weather (hidden until data loads) */
.weather { display: none; }
.weather.show { display: block; }

.location {
    font-size: 1.1rem;
    font-weight: 800;
    text-align: center;
    margin-bottom: 6px;
}
.date {
    text-align: center;
    font-size: 0.78rem;
    color: #64748b;
    margin-bottom: 20px;
}

/* Current weather card */
.current {
    background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%);
    border-radius: 16px;
    padding: 24px;
    color: #fff;
    text-align: center;
    margin-bottom: 18px;
}
.current .icon {
    font-size: 3.5rem;
    line-height: 1;
    margin-bottom: 8px;
}
.current .temp {
    font-size: 3.2rem;
    font-weight: 900;
    line-height: 1;
    letter-spacing: -0.03em;
    font-family: "Courier New", monospace;
}
.current .condition {
    font-size: 0.95rem;
    opacity: 0.95;
    margin-top: 6px;
    font-weight: 600;
}

.meta {
    display: grid;
    grid-template-columns: repeat(3, 1fr);
    gap: 8px;
    margin-top: 16px;
}
.meta-item {
    background: rgba(255,255,255,0.15);
    border-radius: 8px;
    padding: 8px 4px;
    font-size: 0.72rem;
}
.meta-item .lbl {
    opacity: 0.85;
    text-transform: uppercase;
    letter-spacing: 0.05em;
    font-weight: 700;
}
.meta-item .val {
    font-weight: 800;
    font-family: "Courier New", monospace;
    margin-top: 3px;
    font-size: 0.9rem;
}

/* Forecast */
.forecast-title {
    font-size: 0.75rem;
    text-transform: uppercase;
    letter-spacing: 0.08em;
    font-weight: 700;
    color: #64748b;
    margin-bottom: 10px;
}
.forecast {
    display: grid;
    grid-template-columns: repeat(5, 1fr);
    gap: 6px;
}
.day {
    background: #f8fafc;
    border: 1px solid #e2e8f0;
    border-radius: 10px;
    padding: 10px 4px;
    text-align: center;
}
.day .name {
    font-size: 0.68rem;
    color: #64748b;
    text-transform: uppercase;
    letter-spacing: 0.05em;
    font-weight: 700;
}
.day .emoji {
    font-size: 1.5rem;
    margin: 6px 0;
}
.day .temp {
    font-size: 0.72rem;
    font-family: "Courier New", monospace;
}
.day .temp .hi { color: #0f172a; font-weight: 800; }
.day .temp .lo { color: #94a3b8; }
3

Write the JavaScript — API Calls with fetch & async/await

The core of the app. Two API calls happen in sequence: first geocoding (city name → coordinates), then weather (coordinates → forecast data).

JavaScript — Part 1: Fetch
// ============================================================
// WMO WEATHER CODES
// ============================================================
// Open-Meteo returns numeric weather codes (per the WMO
// standard). We map them to icons and text.

var WMO_CODES = {
    0:  { icon: "☀️", text: "Clear sky" },
    1:  { icon: "🌤️", text: "Mainly clear" },
    2:  { icon: "⛅", text: "Partly cloudy" },
    3:  { icon: "☁️", text: "Overcast" },
    45: { icon: "🌫️", text: "Fog" },
    48: { icon: "🌫️", text: "Fog" },
    51: { icon: "🌦️", text: "Light drizzle" },
    53: { icon: "🌦️", text: "Drizzle" },
    55: { icon: "🌧️", text: "Heavy drizzle" },
    61: { icon: "🌧️", text: "Light rain" },
    63: { icon: "🌧️", text: "Rain" },
    65: { icon: "🌧️", text: "Heavy rain" },
    71: { icon: "🌨️", text: "Light snow" },
    73: { icon: "❄️", text: "Snow" },
    75: { icon: "❄️", text: "Heavy snow" },
    80: { icon: "🌦️", text: "Rain showers" },
    81: { icon: "🌧️", text: "Rain showers" },
    82: { icon: "⛈️", text: "Violent showers" },
    95: { icon: "⛈️", text: "Thunderstorm" },
    96: { icon: "⛈️", text: "Thunderstorm" },
    99: { icon: "⛈️", text: "Thunderstorm" }
};

function codeInfo(code) {
    return WMO_CODES[code] || { icon: "🌡️", text: "Unknown" };
}

// ============================================================
// DOM ELEMENTS
// ============================================================

var form = document.getElementById("searchForm");
var input = document.getElementById("cityInput");
var btn = document.getElementById("searchBtn");
var statusEl = document.getElementById("status");
var weatherEl = document.getElementById("weather");
var errorEl = document.getElementById("errorMsg");

// ============================================================
// UI STATE HELPERS
// ============================================================
// Three states: loading, error, success. Only one shows at a time.

function showStatus(msg) {
    statusEl.style.display = "block";
    statusEl.innerHTML = '<div class="spinner"></div><div>' + msg + '</div>';
    weatherEl.classList.remove("show");
    errorEl.classList.remove("show");
}

function showError(msg) {
    statusEl.style.display = "none";
    weatherEl.classList.remove("show");
    errorEl.textContent = "❌ " + msg;
    errorEl.classList.add("show");
}

function showWeather() {
    statusEl.style.display = "none";
    errorEl.classList.remove("show");
    weatherEl.classList.add("show");
}

// ============================================================
// MAIN WEATHER FETCH
// ============================================================
// Two-step process:
//   1. Geocoding API: "London" -> { latitude: 51.5, longitude: -0.12 }
//   2. Weather API:  { lat, lon } -> current + 5-day forecast

async function getWeather(city) {
    showStatus("Loading weather...");
    btn.disabled = true;

    try {
        // ---- Step 1: Geocode ----
        var geoRes = await fetch(
            "https://geocoding-api.open-meteo.com/v1/search?name=" +
            encodeURIComponent(city) + "&count=1&language=en&format=json"
        );

        // fetch() only rejects on network failure.
        // HTTP errors (404, 500) still resolve — check .ok manually.
        if (!geoRes.ok) {
            throw new Error("Geocoding request failed");
        }

        var geo = await geoRes.json();

        if (!geo.results || geo.results.length === 0) {
            throw new Error("City not found. Try a different spelling.");
        }

        var place = geo.results[0];
        var lat = place.latitude;
        var lon = place.longitude;

        // ---- Step 2: Fetch weather ----
        var wRes = await fetch(
            "https://api.open-meteo.com/v1/forecast" +
            "?latitude=" + lat +
            "&longitude=" + lon +
            "¤t=temperature_2m,apparent_temperature,relative_humidity_2m,weather_code,wind_speed_10m" +
            "&daily=weather_code,temperature_2m_max,temperature_2m_min" +
            "&timezone=auto" +
            "&forecast_days=5"
        );

        if (!wRes.ok) {
            throw new Error("Weather request failed");
        }

        var weather = await wRes.json();

        // ---- Render ----
        renderWeather(place, weather);
        showWeather();

    } catch (err) {
        // Any error — network, HTTP, or logic — lands here
        showError(err.message || "Something went wrong.");
    } finally {
        // Always runs — enables the button whether we succeeded or not
        btn.disabled = false;
    }
}

Common trap: fetch() only rejects the promise on network failure — not on HTTP errors like 404 or 500. You must check response.ok yourself. This trips up almost every developer the first time.

What does async do? It tells JavaScript the function returns a Promise. Inside an async function, await pauses execution until the promise resolves — and lets you write code that reads top-to-bottom even though it's asynchronous under the hood.

4

Write the JavaScript — Render the Data

JavaScript — Part 2: Render
// ============================================================
// RENDER WEATHER DATA
// ============================================================
// Takes the geocoded place object and the weather API response,
// fills in the DOM.

function renderWeather(place, data) {
    // ---- Location name ----
    var name = place.name;
    if (place.admin1) name += ", " + place.admin1;
    if (place.country && place.country !== place.admin1) {
        name += ", " + place.country;
    }
    document.getElementById("location").textContent = name;

    // ---- Today's date ----
    var now = new Date();
    document.getElementById("date").textContent = now.toLocaleDateString(undefined, {
        weekday: "long",
        month: "long",
        day: "numeric"
    });

    // ---- Current conditions ----
    var cur = data.current;
    var info = codeInfo(cur.weather_code);

    document.getElementById("currentIcon").textContent = info.icon;
    document.getElementById("currentTemp").textContent =
        Math.round(cur.temperature_2m) + "°C";
    document.getElementById("currentCondition").textContent = info.text;
    document.getElementById("feelsLike").textContent =
        Math.round(cur.apparent_temperature) + "°";
    document.getElementById("wind").textContent =
        Math.round(cur.wind_speed_10m) + " km/h";
    document.getElementById("humidity").textContent =
        cur.relative_humidity_2m + "%";

    // ---- 5-Day Forecast ----
    var fc = document.getElementById("forecast");
    var html = "";

    // data.daily.time = ["2026-10-05", "2026-10-06", ...]
    // data.daily.weather_code = [1, 3, 61, ...]
    // data.daily.temperature_2m_max = [18, 17, 15, ...]
    // data.daily.temperature_2m_min = [10, 9, 8, ...]

    for (var i = 0; i < data.daily.time.length; i++) {
        var d = new Date(data.daily.time[i]);
        var dayName = d.toLocaleDateString(undefined, { weekday: "short" });
        var di = codeInfo(data.daily.weather_code[i]);

        html += '<div class="day">' +
            '<div class="name">' + dayName + '</div>' +
            '<div class="emoji">' + di.icon + '</div>' +
            '<div class="temp">' +
                '<span class="hi">' + Math.round(data.daily.temperature_2m_max[i]) + '°</span> ' +
                '<span class="lo">' + Math.round(data.daily.temperature_2m_min[i]) + '°</span>' +
            '</div>' +
            '</div>';
    }
    fc.innerHTML = html;
}
5

Write the JavaScript — Wire Up Events

JavaScript — Part 3: Events
// ============================================================
// EVENT HANDLERS
// ============================================================

// Form submit — handles Enter key and button click
form.addEventListener("submit", function(e) {
    e.preventDefault();
    var city = input.value.trim();
    if (city) {
        getWeather(city);
    }
});

// ============================================================
// BOOT — load a default city on page open
// ============================================================

getWeather(input.value);

Save and open index.html. The app loads London's weather by default. Type "Tokyo" and press Enter — it fetches Tokyo. Try "XYZABC" — you should see a friendly error message.

6

Understand the Big Ideas

1. Promises and async/await

Fetching data takes time. JavaScript doesn't wait — it hands you a Promise that resolves later. Without async/await, you'd write:

Old way (Promise chains)
fetch(url)
    .then(function(res) { return res.json(); })
    .then(function(data) { render(data); })
    .catch(function(err) { showError(err); });

With async/await, the same code reads top-to-bottom:

Modern way (async/await)
async function fetchData() {
    try {
        var res = await fetch(url);
        var data = await res.json();
        render(data);
    } catch (err) {
        showError(err);
    }
}

Same behavior, much cleaner. This is why modern code uses async/await.

2. The three states of an async UI

Any time you fetch data, you have three possible states:

Your job is to make sure the UI accurately reflects whichever state you're in. The pattern: show loading, then either show data OR show error — never both, never nothing.

3. Why two API calls?

Open-Meteo splits geocoding from weather because weather is location-based, not name-based. Every weather API works this way — some (like OpenWeatherMap) hide the geocoding step inside one endpoint, but the two-step pattern is universal.

You just learned the pattern behind: Google Maps search, Uber pickup, food delivery, flight trackers, currency converters, and every other app that takes a user query and returns location-specific data.

4. try / catch / finally

JavaScript
try {
    // code that might throw
} catch (err) {
    // runs if anything in try throws
} finally {
    // ALWAYS runs — success or failure
}

finally is where you re-enable the search button. Without it, if the request fails, the button stays disabled forever.

5. encodeURIComponent()

encodeURIComponent("New York") returns "New%20York". Without this, spaces and special characters break the URL. Always wrap user input when you put it in a URL query string.

7

Practice Challenges

🟢 Beginner

🟡 Intermediate

🔴 Advanced

Full Source Code

Common Mistakes & Fixes

❌ "TypeError: Failed to fetch"

The most common cause is a network error, but it also appears when:
• The API URL is wrong (typo in the domain)
• Ad blockers or privacy extensions are blocking the request
• A browser extension (like uBlock) blocks api.open-meteo.com
Try in a private window with no extensions. If it works there, an extension is the culprit.

❌ "Nothing happens when I search"

Check DevTools Console for errors. Common causes:
• You forgot to attach the form's submit handler
• You forgot e.preventDefault(), so the page reloads
• getWeather() is defined below its usage but not hoisted correctly (use function declaration, not arrow function assigned to a const)

❌ "The data comes back but nothing shows"

Open DevTools Console and add a console.log(data) at the top of renderWeather(). This shows the actual shape of the API response. Then check that your field names match — data.current.temperature_2m, not data.current.temp.

❌ "CORS error"

Open-Meteo allows CORS, so you shouldn't see this. If you do, you're calling a different API that doesn't set the CORS header. This is a server-side issue you can't fix on the client — you'd need a proxy.

❌ "Search button stays greyed out"

You forgot the finally block, or you return early without hitting it. Every code path in the try/catch must reach the finally to re-enable the button.

❌ "City not found" for a valid city

The geocoding API prefers exact city names. "NYC" won't find New York. "New York" will. Try the full city name first.

❌ "Time is off"

The forecast times use the city's local timezone (timezone=auto). If you see times that feel wrong, the API is showing the searched city's local time, not yours.

What to Build Next