Build a Weather Dashboard App (Step by Step)
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
- Go to OpenWeatherMap and sign up (free tier: 60 calls/minute)
- Navigate to API Keys in your account
- 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-dotenvCreate a .env file:
WEATHER_API_KEY=your_key_here
SECRET_KEY=generate-a-random-stringStep 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.pyVisit 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
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