Skip to content
Build a Weather Dashboard App (Step by Step)

Build a Weather Dashboard App (Step by Step)

DodaTech Updated Jun 19, 2026 9 min read

Build a weather dashboard app with Python Flask that searches cities, displays current weather and a 5-day forecast, shows temperature/humidity/wind data, and handles invalid city names gracefully.

What You’ll Build

You’ll build a weather dashboard where users type a city name and see current conditions (temperature, humidity, wind speed, weather icon) plus a 5-day forecast with daily highs and lows. The app uses the OpenWeatherMap API for data and presents it in a clean, responsive card layout. DodaTech’s Doda Browser uses similar API integration patterns for its weather widget and default start page.

Why Build a Weather App?

Weather APIs are the perfect introduction to third-party API integration. You’ll learn how to sign up for an API key, make authenticated requests, parse JSON responses, handle rate limits and errors, and present external data in your own UI. Every production app integrates external APIs — weather, payments, maps, authentication — and this project teaches the pattern.

Prerequisites

Step 1: Get an API Key

  1. Go to OpenWeatherMap and sign up (free tier: 60 calls/minute)
  2. Navigate to API Keys in your account
  3. Copy your key (looks like a1b2c3d4e5f6...)
mkdir weather-dashboard
cd weather-dashboard
python -m venv venv
source venv/bin/activate
pip install flask requests python-dotenv

Create a .env file:

WEATHER_API_KEY=your_key_here
SECRET_KEY=generate-a-random-string

Step 2: Weather API Client

# weather.py
import requests
from datetime import datetime
from typing import Optional, Dict, Any

API_BASE = "https://api.openweathermap.org/data/2.5"

class WeatherClient:
    def __init__(self, api_key: str):
        self.api_key = api_key
        self.session = requests.Session()

    def get_current_weather(self, city: str) -> Optional[Dict[str, Any]]:
        """Fetch current weather for a city."""
        url = f"{API_BASE}/weather"
        params = {
            "q": city,
            "appid": self.api_key,
            "units": "metric",
            "lang": "en",
        }
        try:
            response = self.session.get(url, params=params, timeout=5)
            response.raise_for_status()
            data = response.json()
            return self._format_current(data)
        except requests.exceptions.HTTPError as e:
            if response.status_code == 404:
                return None  # City not found
            raise
        except requests.exceptions.RequestException as e:
            raise ConnectionError(f"Failed to fetch weather data: {e}")

    def get_forecast(self, city: str) -> Optional[list]:
        """Fetch 5-day/3-hour forecast, return daily summaries."""
        url = f"{API_BASE}/forecast"
        params = {
            "q": city,
            "appid": self.api_key,
            "units": "metric",
            "lang": "en",
        }
        try:
            response = self.session.get(url, params=params, timeout=5)
            response.raise_for_status()
            data = response.json()
            return self._summarize_daily(data)
        except requests.exceptions.HTTPError as e:
            if response.status_code == 404:
                return None
            raise
        except requests.exceptions.RequestException as e:
            raise ConnectionError(f"Failed to fetch forecast: {e}")

    def _format_current(self, data: dict) -> dict:
        return {
            "city": data["name"],
            "country": data["sys"]["country"],
            "temperature": round(data["main"]["temp"]),
            "feels_like": round(data["main"]["feels_like"]),
            "humidity": data["main"]["humidity"],
            "pressure": data["main"]["pressure"],
            "wind_speed": round(data["wind"]["speed"], 1),
            "description": data["weather"][0]["description"].capitalize(),
            "icon": data["weather"][0]["icon"],
            "icon_url": f"https://openweathermap.org/img/wn/{data['weather'][0]['icon']}@2x.png",
            "clouds": data["clouds"]["all"],
            "visibility": data.get("visibility", 0),
            "sunrise": datetime.fromtimestamp(data["sys"]["sunrise"]).strftime("%H:%M"),
            "sunset": datetime.fromtimestamp(data["sys"]["sunset"]).strftime("%H:%M"),
        }

    def _summarize_daily(self, data: dict) -> list:
        """
        The forecast API returns 3-hour intervals (40 data points over 5 days).
        We group by date and compute daily min/max.
        """
        from collections import defaultdict
        daily = defaultdict(lambda: {"temps": [], "icons": [], "descriptions": []})

        for item in data["list"]:
            dt = datetime.fromtimestamp(item["dt"])
            date_key = dt.strftime("%Y-%m-%d")
            daily[date_key]["temps"].append(item["main"]["temp"])
            daily[date_key]["icons"].append(item["weather"][0]["icon"])
            daily[date_key]["descriptions"].append(item["weather"][0]["description"])

        forecast = []
        for date_key, values in sorted(daily.items())[:5]:
            forecast.append({
                "date": date_key,
                "day_name": datetime.strptime(date_key, "%Y-%m-%d").strftime("%A"),
                "temp_min": round(min(values["temps"])),
                "temp_max": round(max(values["temps"])),
                "icon": values["icons"][len(values["icons"]) // 2],  # Midday icon
                "description": values["descriptions"][len(values["descriptions"]) // 2].capitalize(),
            })

        return forecast

    def close(self):
        self.session.close()

Step 3: Flask App

# app.py
from flask import Flask, request, render_template, jsonify
from weather import WeatherClient
from dotenv import load_dotenv
import os

load_dotenv()

app = Flask(__name__)
app.secret_key = os.getenv("SECRET_KEY", "dev-secret")
weather_client = WeatherClient(os.getenv("WEATHER_API_KEY"))

@app.route("/")
def index():
    return render_template("index.html", weather=None, forecast=None, error=None, city="")

@app.route("/weather", methods=["POST"])
def get_weather():
    city = request.form.get("city", "").strip()
    if not city:
        return render_template("index.html", error="Please enter a city name", weather=None, forecast=None, city="")

    try:
        current = weather_client.get_current_weather(city)
        forecast = weather_client.get_forecast(city)

        if current is None:
            return render_template("index.html", error=f"City '{city}' not found. Check the spelling and try again.", weather=None, forecast=None, city=city)

        return render_template("index.html", weather=current, forecast=forecast, error=None, city=city)

    except ConnectionError as e:
        return render_template("index.html", error=str(e), weather=None, forecast=None, city=city)
    except Exception as e:
        return render_template("index.html", error="An unexpected error occurred. Please try again later.", weather=None, forecast=None, city=city)

@app.route("/api/weather", methods=["GET"])
def api_weather():
    """JSON API endpoint for programmatic access."""
    city = request.args.get("city", "").strip()
    if not city:
        return jsonify({"error": "City parameter required"}), 400
    try:
        current = weather_client.get_current_weather(city)
        if current is None:
            return jsonify({"error": "City not found"}), 404
        forecast = weather_client.get_forecast(city)
        return jsonify({"current": current, "forecast": forecast})
    except Exception as e:
        return jsonify({"error": str(e)}), 500

if __name__ == "__main__":
    app.run(debug=True, port=5000)

Step 4: Frontend Template

<!-- templates/index.html -->
<!DOCTYPE html>
<html>
<head>
    <title>Weather Dashboard</title>
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body { font-family: system-ui, sans-serif; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); min-height: 100vh; padding: 40px 20px; }
        .container { max-width: 700px; margin: 0 auto; }
        h1 { color: white; text-align: center; margin-bottom: 30px; font-weight: 300; font-size: 2.2em; }
        .search-box { display: flex; gap: 10px; margin-bottom: 30px; }
        .search-box input { flex: 1; padding: 14px 18px; border: none; border-radius: 12px; font-size: 16px; background: rgba(255,255,255,0.9); }
        .search-box input:focus { outline: none; background: white; }
        .search-box button { padding: 14px 28px; background: #ff6b6b; color: white; border: none; border-radius: 12px; font-size: 16px; cursor: pointer; transition: background 0.2s; }
        .search-box button:hover { background: #ee5a24; }
        .error { background: rgba(255,255,255,0.9); color: #d32f2f; padding: 12px 18px; border-radius: 10px; margin-bottom: 20px; text-align: center; }
        .weather-card { background: rgba(255,255,255,0.95); border-radius: 16px; padding: 30px; margin-bottom: 24px; box-shadow: 0 8px 32px rgba(0,0,0,0.1); }
        .weather-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
        .weather-header h2 { font-size: 1.8em; color: #333; }
        .weather-header .temp-main { font-size: 3em; font-weight: 700; color: #333; }
        .weather-details { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 12px; }
        .detail { background: #f8f9fa; padding: 14px; border-radius: 10px; text-align: center; }
        .detail .label { font-size: 0.85em; color: #888; margin-bottom: 4px; }
        .detail .value { font-size: 1.2em; font-weight: 600; color: #333; }
        .forecast-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 12px; }
        .forecast-day { background: rgba(255,255,255,0.95); border-radius: 12px; padding: 16px; text-align: center; box-shadow: 0 4px 16px rgba(0,0,0,0.08); }
        .forecast-day .day-name { font-weight: 600; color: #555; margin-bottom: 8px; }
        .forecast-day .temps { display: flex; justify-content: center; gap: 8px; font-size: 0.9em; }
        .forecast-day .high { font-weight: 600; color: #e74c3c; }
        .forecast-day .low { color: #3498db; }
        .section-title { color: white; font-size: 1.3em; margin-bottom: 16px; font-weight: 300; }
        .icon-text { display: flex; align-items: center; gap: 8px; justify-content: center; }
        img.weather-icon { width: 60px; height: 60px; }
        .desc { color: #666; font-size: 1.1em; }
        .powered { text-align: center; color: rgba(255,255,255,0.6); font-size: 0.85em; margin-top: 30px; }
    </style>
</head>
<body>
    <div class="container">
        <h1>Weather Dashboard</h1>
        <form class="search-box" method="POST" action="/weather">
            <input type="text" name="city" placeholder="Enter city name..." value="{{ city }}" required>
            <button type="submit">Search</button>
        </form>

        {% if error %}
        <div class="error">{{ error }}</div>
        {% endif %}

        {% if weather %}
        <div class="weather-card">
            <div class="weather-header">
                <div>
                    <h2>{{ weather.city }}, {{ weather.country }}</h2>
                    <div class="icon-text">
                        <img class="weather-icon" src="{{ weather.icon_url }}" alt="{{ weather.description }}">
                        <span class="desc">{{ weather.description }}</span>
                    </div>
                </div>
                <div class="temp-main">{{ weather.temperature }}°C</div>
            </div>
            <div class="weather-details">
                <div class="detail"><div class="label">Feels Like</div><div class="value">{{ weather.feels_like }}°C</div></div>
                <div class="detail"><div class="label">Humidity</div><div class="value">{{ weather.humidity }}%</div></div>
                <div class="detail"><div class="label">Wind</div><div class="value">{{ weather.wind_speed }} m/s</div></div>
                <div class="detail"><div class="label">Pressure</div><div class="value">{{ weather.pressure }} hPa</div></div>
                <div class="detail"><div class="label">Cloudiness</div><div class="value">{{ weather.clouds }}%</div></div>
                <div class="detail"><div class="label">Visibility</div><div class="value">{{ weather.visibility // 1000 }} km</div></div>
                <div class="detail"><div class="label">Sunrise</div><div class="value">{{ weather.sunrise }}</div></div>
                <div class="detail"><div class="label">Sunset</div><div class="value">{{ weather.sunset }}</div></div>
            </div>
        </div>

        <h3 class="section-title">5-Day Forecast</h3>
        <div class="forecast-grid">
            {% for day in forecast %}
            <div class="forecast-day">
                <div class="day-name">{{ day.day_name }}</div>
                <img src="https://openweathermap.org/img/wn/{{ day.icon }}@2x.png" alt="" style="width:50px;height:50px;">
                <div>{{ day.description }}</div>
                <div class="temps">
                    <span class="high">{{ day.temp_max }}°</span>
                    <span class="low">{{ day.temp_min }}°</span>
                </div>
            </div>
            {% endfor %}
        </div>
        {% endif %}

        <div class="powered">Powered by OpenWeatherMap</div>
    </div>
</body>
</html>

Step 5: Run

python app.py

Visit http://localhost:5000. Type “London” and click Search.

Expected output:

  • Current weather card showing London, UK with temperature, humidity, wind, and weather icon
  • 5-day forecast grid with day names, icons, and high/low temperatures
  • Each detail card shows a different metric with clean spacing

Try “InvalidCity123” — you’ll see an error message: “City ‘InvalidCity123’ not found.”

Architecture


sequenceDiagram
    participant User as User (Browser)
    participant App as Flask Server
    participant Client as WeatherClient
    participant OWM as OpenWeatherMap API

    User->>App: POST /weather (city="London")
    App->>Client: get_current_weather("London")
    Client->>OWM: GET /data/2.5/weather?q=London&appid=KEY
    OWM-->>Client: JSON (current weather)
    Client->>Client: Format response
    Client-->>App: dict with temp, humidity, etc.
    App->>Client: get_forecast("London")
    Client->>OWM: GET /data/2.5/forecast?q=London&appid=KEY
    OWM-->>Client: JSON (40 data points)
    Client->>Client: Summarize to daily
    Client-->>App: list of 5 daily summaries
    App-->>User: Render template with data

Common Errors

1. “City not found” for valid cities OpenWeatherMap uses English names. “Köln” won’t work — use “Cologne”. “北京” won’t work — use “Beijing”. Some cities share names with larger cities and might return the wrong one. Use the city ID or geocoding API for better accuracy.

2. 401 Unauthorized — Invalid API key Your API key is wrong or not activated. New keys take up to 2 hours to activate. Check the key in your OpenWeatherMap account dashboard. Ensure the .env file has WEATHER_API_KEY=your_key_here with no quotes or spaces.

3. Rate limit exceeded (429 Too Many Requests) The free plan allows 60 calls per minute. Our app makes 2 calls per search (current + forecast). If you’re testing aggressively, you’ll hit the limit. Add a delay or cache results for the same city within a 10-minute window.

4. Forecast shows wrong days The free API returns 3-hour intervals (40 points covering 5 days). Our _summarize_daily() function groups by date and takes the first 5 days. If the API returns data starting 6 hours from now, “day 1” might be today or tomorrow depending on when you query. For production, align to the user’s local timezone.

Practice Questions

1. Why do we format the API response in _format_current()? The raw OpenWeatherMap JSON is verbose and uses different naming (e.g., main.temp). We extract only the fields our template needs, rename them for clarity, and compute derived values (like icon_url). This keeps our template clean and our API client reusable.

2. How does the 5-day forecast summarization work? The OpenWeatherMap forecast API returns 40 data points (8 per day × 5 days). _summarize_daily() groups them by date, computes the min and max temperature for each day, picks the midday icon and description, and returns 5 daily objects.

3. What happens when the API key is invalid? The OpenWeatherMap API returns HTTP 401. response.raise_for_status() raises HTTPError. Our except block catches it and re-raises as a generic error. The user sees “An unexpected error occurred.” It’s better to distinguish between “city not found” (404) and “auth failed” (401).

4. Challenge: Add geolocation Use the browser’s Geolocation API to auto-detect the user’s city on first load. navigator.geolocation.getCurrentPosition() returns coordinates. Use OpenWeatherMap’s reverse geocoding to convert to a city name. Show “Weather for your location” as the default view.

5. Challenge: Add hourly forecast Create a new tab/button on the forecast that shows the raw 3-hour intervals instead of daily summaries. Display a line chart (using Chart.js or Canvas) showing temperature changes throughout the week.

FAQ

Can I use a different weather API?
Yes. The app uses OpenWeatherMap, but you can swap it for WeatherAPI, Weatherstack, or Visual Crossing. The key is implementing the same interface — your Flask app shouldn’t care which provider you use. Define an abstract WeatherClient class and swap implementations.
How do I add weather icons?
We use OpenWeatherMap’s icon URLs (https://openweathermap.org/img/wn/{icon}@2x.png). The @2x gives retina-quality. For custom icons, map the icon codes (01d, 02d, etc.) to your own icon set.
How do I handle timezones?
OpenWeatherMap returns timestamps in Unix UTC. Use datetime.utcfromtimestamp(). The free API doesn’t include timezone offset — for local time display, add the city’s timezone from a separate API. The pytz library can help with conversion.

Next Steps

  • Add Redis caching to avoid duplicate API calls
  • Learn Docker to containerize the app
  • Explore the CSS Flexbox tutorial to improve the responsive layout
  • Check the Real-Time Dashboard project for live-updating charts

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro