← Back to DevBytes

How to Build a Weather App with OpenWeatherMap API

What is the OpenWeatherMap API?

The OpenWeatherMap API is a powerful, RESTful web service that provides access to current weather data, forecasts, historical weather data, and a variety of meteorological information for any location on Earth. It serves as the data backbone for thousands of weather applications, websites, and IoT devices worldwide. The API returns structured JSON responses containing temperature, humidity, wind speed, atmospheric pressure, weather conditions, sunrise/sunset times, and much more — all derived from a global network of weather stations, radar systems, and satellite data.

Why Use OpenWeatherMap for Your Weather App?

OpenWeatherMap stands out as the go-to choice for weather data integration for several compelling reasons:

Getting Started — API Key and Setup

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

Before writing a single line of code, you need to obtain an API key. Here's the process:

  1. Visit openweathermap.org and click "Sign Up" to create a free account.
  2. After verifying your email, log in and navigate to the "API Keys" section in your account dashboard.
  3. You'll find a default API key already generated — copy this key. It will look something like: a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6.
  4. Important: The free API key may take up to 2 hours to activate after initial sign-up. Plan accordingly.
  5. Store your API key securely. Never commit it to public GitHub repositories. Use environment variables or configuration files excluded from version control.

Understanding the API Endpoints

OpenWeatherMap offers several endpoints. For a standard weather app, the most relevant are:

Current Weather Data Endpoint

https://api.openweathermap.org/data/2.5/weather?q={city}&appid={API_KEY}&units=metric

This endpoint returns current weather conditions for a specified city. Parameters include the city name, your API key, and optional units (metric for Celsius, imperial for Fahrenheit, or leave blank for Kelvin). You can also search by city ID, geographic coordinates, or ZIP code.

5-Day Forecast Endpoint (3-Hour Intervals)

https://api.openweathermap.org/data/2.5/forecast?q={city}&appid={API_KEY}&units=metric

This returns weather forecasts in 3-hour intervals for the next 5 days (40 data points total). Each forecast block contains temperature, weather conditions, wind, and atmospheric data at a specific timestamp.

Response Structure for Current Weather

A typical JSON response from the current weather endpoint looks like this:

{
  "coord": { "lon": -0.1257, "lat": 51.5085 },
  "weather": [
    {
      "id": 802,
      "main": "Clouds",
      "description": "scattered clouds",
      "icon": "03d"
    }
  ],
  "main": {
    "temp": 15.2,
    "feels_like": 14.8,
    "temp_min": 13.9,
    "temp_max": 16.5,
    "pressure": 1012,
    "humidity": 72
  },
  "wind": {
    "speed": 4.1,
    "deg": 320
  },
  "clouds": { "all": 40 },
  "dt": 1684924800,
  "sys": {
    "country": "GB",
    "sunrise": 1684904400,
    "sunset": 1684959600
  },
  "timezone": 3600,
  "id": 2643743,
  "name": "London",
  "cod": 200
}

Key fields to extract: main.temp for temperature, weather[0].description for the human-readable condition, weather[0].icon for the weather icon code, main.humidity for humidity percentage, and wind.speed for wind speed.

Building the Weather App — Step by Step

Project Structure

We'll build a single-page weather application using vanilla HTML, CSS, and JavaScript — no frameworks, no build tools. This approach ensures the tutorial is accessible to developers at any level. The app will feature:

HTML — Building the User Interface

We start with a clean, semantic HTML structure. The layout consists of a header with the app title, a search form, a main weather display card, and a forecast section with horizontally-scrollable forecast cards.

<!-- The HTML skeleton for our weather app -->
<div class="container">
  <header>
    <h1>WeatherVue</h1>
    <p>Real-time weather at your fingertips</p>
  </header>

  <div class="search-section">
    <form id="searchForm">
      <input 
        type="text" 
        id="cityInput" 
        placeholder="Enter city name..." 
        autocomplete="off"
        required
      />
      <button type="submit" id="searchBtn">
        <span class="search-icon">🔍</span> Search
      </button>
    </form>
    <p class="error-message" id="errorMsg"></p>
  </div>

  <div class="weather-display" id="weatherDisplay">
    <!-- Dynamically populated by JavaScript -->
    <div class="loading-spinner" id="loadingSpinner">
      <div class="spinner"></div>
      <p>Fetching weather data...</p>
    </div>
  </div>

  <div class="forecast-section" id="forecastSection">
    <h2>5-Day Forecast</h2>
    <div class="forecast-cards" id="forecastCards">
      <!-- Dynamically populated by JavaScript -->
    </div>
  </div>
</div>

CSS — Styling the App

The CSS creates a modern, card-based design with gradient backgrounds, smooth transitions, and responsive breakpoints. We use CSS custom properties (variables) for consistent theming.

/* CSS Variables for consistent theming */
:root {
  --bg-primary: #1a1a2e;
  --bg-secondary: #16213e;
  --bg-card: #0f3460;
  --accent: #e94560;
  --text-primary: #ffffff;
  --text-secondary: #a0a0b0;
  --border-radius: 12px;
  --shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
}

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

body {
  font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
  background: linear-gradient(135deg, var(--bg-primary) 0%, var(--bg-secondary) 100%);
  color: var(--text-primary);
  min-height: 100vh;
  display: flex;
  justify-content: center;
  padding: 20px;
}

.container {
  max-width: 800px;
  width: 100%;
}

header {
  text-align: center;
  margin-bottom: 30px;
}

header h1 {
  font-size: 2.5rem;
  background: linear-gradient(135deg, #e94560, #ff6b6b);
  -webkit-background-clip: text;
  -webkit-text-fill-color: transparent;
  background-clip: text;
}

header p {
  color: var(--text-secondary);
  font-size: 1rem;
  margin-top: 8px;
}

/* Search Section */
.search-section {
  margin-bottom: 30px;
}

#searchForm {
  display: flex;
  gap: 10px;
}

#cityInput {
  flex: 1;
  padding: 14px 18px;
  border: 2px solid rgba(255, 255, 255, 0.1);
  border-radius: var(--border-radius);
  background: rgba(255, 255, 255, 0.05);
  color: var(--text-primary);
  font-size: 1rem;
  outline: none;
  transition: border-color 0.3s, box-shadow 0.3s;
}

#cityInput:focus {
  border-color: var(--accent);
  box-shadow: 0 0 0 3px rgba(233, 69, 96, 0.2);
}

#searchBtn {
  padding: 14px 24px;
  background: var(--accent);
  color: white;
  border: none;
  border-radius: var(--border-radius);
  font-size: 1rem;
  cursor: pointer;
  transition: transform 0.2s, box-shadow 0.2s;
  display: flex;
  align-items: center;
  gap: 6px;
}

#searchBtn:hover {
  transform: translateY(-2px);
  box-shadow: 0 4px 16px rgba(233, 69, 96, 0.4);
}

#searchBtn:active {
  transform: translateY(0);
}

.error-message {
  color: #ff6b6b;
  font-size: 0.9rem;
  margin-top: 8px;
  min-height: 20px;
}

/* Weather Display Card */
.weather-display {
  min-height: 200px;
}

.current-weather-card {
  background: var(--bg-card);
  border-radius: var(--border-radius);
  padding: 30px;
  box-shadow: var(--shadow);
  animation: fadeIn 0.5s ease-in;
}

@keyframes fadeIn {
  from { opacity: 0; transform: translateY(20px); }
  to { opacity: 1; transform: translateY(0); }
}

.city-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 20px;
}

.city-name {
  font-size: 1.8rem;
  font-weight: 600;
}

.country-code {
  font-size: 0.9rem;
  color: var(--text-secondary);
  background: rgba(255, 255, 255, 0.1);
  padding: 4px 10px;
  border-radius: 20px;
}

.weather-main {
  display: flex;
  align-items: center;
  gap: 20px;
  margin-bottom: 20px;
}

.weather-icon-large {
  width: 100px;
  height: 100px;
}

.temperature {
  font-size: 4rem;
  font-weight: 300;
  line-height: 1;
}

.temperature span {
  font-size: 1.5rem;
  color: var(--text-secondary);
}

.weather-description {
  font-size: 1.2rem;
  text-transform: capitalize;
  color: var(--text-secondary);
}

.weather-details {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
  gap: 16px;
  margin-top: 20px;
}

.detail-item {
  background: rgba(255, 255, 255, 0.05);
  border-radius: 8px;
  padding: 14px;
  text-align: center;
}

.detail-label {
  font-size: 0.75rem;
  color: var(--text-secondary);
  text-transform: uppercase;
  letter-spacing: 1px;
}

.detail-value {
  font-size: 1.3rem;
  font-weight: 600;
  margin-top: 6px;
}

/* Forecast Section */
.forecast-section h2 {
  font-size: 1.4rem;
  margin-bottom: 16px;
  color: var(--text-secondary);
}

.forecast-cards {
  display: flex;
  gap: 12px;
  overflow-x: auto;
  padding-bottom: 10px;
  scroll-behavior: smooth;
}

.forecast-cards::-webkit-scrollbar {
  height: 6px;
}

.forecast-cards::-webkit-scrollbar-thumb {
  background: var(--accent);
  border-radius: 3px;
}

.forecast-card {
  min-width: 140px;
  background: var(--bg-card);
  border-radius: var(--border-radius);
  padding: 20px 16px;
  text-align: center;
  flex-shrink: 0;
  box-shadow: var(--shadow);
  transition: transform 0.2s;
}

.forecast-card:hover {
  transform: translateY(-4px);
}

.forecast-date {
  font-size: 0.85rem;
  color: var(--text-secondary);
  margin-bottom: 8px;
}

.forecast-icon {
  width: 50px;
  height: 50px;
  margin: 0 auto;
}

.forecast-temp {
  font-size: 1.4rem;
  font-weight: 600;
  margin: 8px 0;
}

.forecast-desc {
  font-size: 0.8rem;
  color: var(--text-secondary);
  text-transform: capitalize;
}

/* Loading Spinner */
.loading-spinner {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  padding: 60px 0;
  color: var(--text-secondary);
}

.spinner {
  width: 40px;
  height: 40px;
  border: 4px solid rgba(255, 255, 255, 0.1);
  border-top: 4px solid var(--accent);
  border-radius: 50%;
  animation: spin 0.8s linear infinite;
  margin-bottom: 16px;
}

@keyframes spin {
  to { transform: rotate(360deg); }
}

.hidden {
  display: none;
}

/* Responsive */
@media (max-width: 600px) {
  header h1 {
    font-size: 1.8rem;
  }
  
  .temperature {
    font-size: 3rem;
  }
  
  .weather-details {
    grid-template-columns: repeat(2, 1fr);
  }
  
  #searchForm {
    flex-direction: column;
  }
  
  #searchBtn {
    justify-content: center;
  }
}

JavaScript — Fetching and Displaying Weather Data

The JavaScript handles API communication, DOM manipulation, error handling, and data processing. We use the Fetch API for HTTP requests and template literals for clean HTML generation. The code is structured into clear, single-responsibility functions.

// API Configuration — Replace with your actual API key
const API_KEY = 'YOUR_OPENWEATHERMAP_API_KEY_HERE';
const BASE_URL = 'https://api.openweathermap.org/data/2.5';

// DOM Element References
const searchForm = document.getElementById('searchForm');
const cityInput = document.getElementById('cityInput');
const weatherDisplay = document.getElementById('weatherDisplay');
const forecastCards = document.getElementById('forecastCards');
const errorMsg = document.getElementById('errorMsg');
const loadingSpinner = document.getElementById('loadingSpinner');
const forecastSection = document.getElementById('forecastSection');

/**
 * Fetches current weather data for a given city
 * @param {string} city - City name to search for
 * @returns {Promise} Parsed weather data object
 */
async function fetchCurrentWeather(city) {
  const url = `${BASE_URL}/weather?q=${encodeURIComponent(city)}&appid=${API_KEY}&units=metric`;
  const response = await fetch(url);
  
  if (!response.ok) {
    if (response.status === 404) {
      throw new Error('City not found. Please check the spelling and try again.');
    } else if (response.status === 401) {
      throw new Error('Invalid API key. Please verify your API key in the code.');
    } else if (response.status === 429) {
      throw new Error('Too many requests. Please wait before trying again.');
    } else {
      throw new Error(`Weather data unavailable (Error ${response.status}). Please try again later.`);
    }
  }
  
  const data = await response.json();
  return data;
}

/**
 * Fetches 5-day forecast data for a given city
 * @param {string} city - City name to search for
 * @returns {Promise} Parsed forecast data object
 */
async function fetchForecast(city) {
  const url = `${BASE_URL}/forecast?q=${encodeURIComponent(city)}&appid=${API_KEY}&units=metric`;
  const response = await fetch(url);
  
  if (!response.ok) {
    throw new Error('Forecast data unavailable.');
  }
  
  const data = await response.json();
  return data;
}

/**
 * Processes raw forecast data to extract one entry per day (midday)
 * @param {Object} forecastData - Raw forecast API response
 * @returns {Array} Array of daily forecast objects
 */
function extractDailyForecasts(forecastData) {
  const dailyForecasts = [];
  const seenDates = new Set();
  
  // The forecast endpoint returns 40 data points (every 3 hours for 5 days)
  // We filter to get one representative forecast per day (closest to 12:00)
  forecastData.list.forEach(forecast => {
    const date = new Date(forecast.dt * 1000);
    const dateString = date.toLocaleDateString('en-US', { 
      weekday: 'short', 
      month: 'short', 
      day: 'numeric' 
    });
    const hour = date.getHours();
    
    // Prefer forecasts around midday for the most representative daily weather
    if (!seenDates.has(dateString) || (hour >= 11 && hour <= 14)) {
      // Remove previous entry for this date if a better one comes along
      const existingIndex = dailyForecasts.findIndex(f => f.dateString === dateString);
      if (existingIndex !== -1 && (hour >= 11 && hour <= 14)) {
        dailyForecasts.splice(existingIndex, 1);
        seenDates.delete(dateString);
      }
      
      if (!seenDates.has(dateString)) {
        seenDates.add(dateString);
        dailyForecasts.push({
          dateString,
          temp: Math.round(forecast.main.temp),
          icon: forecast.weather[0].icon,
          description: forecast.weather[0].description,
          humidity: forecast.main.humidity,
          wind: forecast.wind.speed,
          dt: forecast.dt
        });
      }
    }
  });
  
  // Return up to 5 days
  return dailyForecasts.slice(0, 5);
}

/**
 * Renders the current weather data into the DOM
 * @param {Object} weatherData - Current weather API response
 */
function renderCurrentWeather(weatherData) {
  const iconCode = weatherData.weather[0].icon;
  const iconUrl = `https://openweathermap.org/img/wn/${iconCode}@2x.png`;
  const sunrise = new Date(weatherData.sys.sunrise * 1000).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
  const sunset = new Date(weatherData.sys.sunset * 1000).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
  const visibility = (weatherData.visibility / 1000).toFixed(1); // Convert meters to km
  
  const html = `
    <div class="current-weather-card">
      <div class="city-header">
        <div>
          <span class="city-name">${weatherData.name}</span>
          <span class="country-code">${weatherData.sys.country}</span>
        </div>
        <div style="color: var(--text-secondary); font-size: 0.9rem;">
          ${new Date().toLocaleDateString('en-US', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' })}
        </div>
      </div>
      
      <div class="weather-main">
        <img src="${iconUrl}" alt="${weatherData.weather[0].description}" class="weather-icon-large" />
        <div>
          <div class="temperature">${Math.round(weatherData.main.temp)}<span>°C</span></div>
          <div class="weather-description">${weatherData.weather[0].description}</div>
        </div>
      </div>
      
      <div class="weather-details">
        <div class="detail-item">
          <div class="detail-label">Feels Like</div>
          <div class="detail-value">${Math.round(weatherData.main.feels_like)}°C</div>
        </div>
        <div class="detail-item">
          <div class="detail-label">Humidity</div>
          <div class="detail-value">${weatherData.main.humidity}%</div>
        </div>
        <div class="detail-item">
          <div class="detail-label">Wind Speed</div>
          <div class="detail-value">${weatherData.wind.speed} m/s</div>
        </div>
        <div class="detail-item">
          <div class="detail-label">Pressure</div>
          <div class="detail-value">${weatherData.main.pressure} hPa</div>
        </div>
        <div class="detail-item">
          <div class="detail-label">Visibility</div>
          <div class="detail-value">${visibility} km</div>
        </div>
        <div class="detail-item">
          <div class="detail-label">Sunrise</div>
          <div class="detail-value">${sunrise}</div>
        </div>
        <div class="detail-item">
          <div class="detail-label">Sunset</div>
          <div class="detail-value">${sunset}</div>
        </div>
        <div class="detail-item">
          <div class="detail-label">Min / Max</div>
          <div class="detail-value">${Math.round(weatherData.main.temp_min)}° / ${Math.round(weatherData.main.temp_max)}°</div>
        </div>
      </div>
    </div>
  `;
  
  weatherDisplay.innerHTML = html;
}

/**
 * Renders the forecast cards into the DOM
 * @param {Array} dailyForecasts - Processed daily forecast array
 */
function renderForecast(dailyForecasts) {
  forecastSection.classList.remove('hidden');
  
  const html = dailyForecasts.map(forecast => {
    const iconUrl = `https://openweathermap.org/img/wn/${forecast.icon}@2x.png`;
    return `
      <div class="forecast-card">
        <div class="forecast-date">${forecast.dateString}</div>
        <img src="${iconUrl}" alt="${forecast.description}" class="forecast-icon" />
        <div class="forecast-temp">${forecast.temp}°C</div>
        <div class="forecast-desc">${forecast.description}</div>
        <div style="font-size: 0.7rem; color: var(--text-secondary); margin-top: 4px;">
          💧 ${forecast.humidity}%   💨 ${forecast.wind} m/s
        </div>
      </div>
    `;
  }).join('');
  
  forecastCards.innerHTML = html;
}

/**
 * Shows the loading spinner and hides other content
 */
function showLoading() {
  weatherDisplay.innerHTML = `
    <div class="loading-spinner">
      <div class="spinner"></div>
      <p>Fetching weather data...</p>
    </div>
  `;
  forecastSection.classList.add('hidden');
  errorMsg.textContent = '';
}

/**
 * Displays an error message to the user
 * @param {string} message - Error message to display
 */
function showError(message) {
  errorMsg.textContent = message;
  weatherDisplay.innerHTML = '';
  forecastSection.classList.add('hidden');
}

/**
 * Main handler: fetches weather and forecast, then renders everything
 * @param {string} city - City name to search for
 */
async function handleCitySearch(city) {
  showLoading();
  
  try {
    // Fetch both current weather and forecast in parallel
    const [weatherData, forecastData] = await Promise.all([
      fetchCurrentWeather(city),
      fetchForecast(city)
    ]);
    
    // Render the results
    renderCurrentWeather(weatherData);
    const dailyForecasts = extractDailyForecasts(forecastData);
    renderForecast(dailyForecasts);
    errorMsg.textContent = '';
    
  } catch (error) {
    showError(error.message);
    console.error('Weather fetch error:', error);
  }
}

/**
 * Event listener for the search form submission
 */
searchForm.addEventListener('submit', function(event) {
  event.preventDefault();
  
  const city = cityInput.value.trim();
  
  if (!city) {
    showError('Please enter a city name.');
    return;
  }
  
  // Validate city name — only allow letters, spaces, hyphens, and apostrophes
  const cityRegex = /^[a-zA-Z\u00C0-\u017F\s'\-\.]+$/;
  if (!cityRegex.test(city)) {
    showError('Please enter a valid city name using letters only.');
    return;
  }
  
  handleCitySearch(city);
});

/**
 * On page load, search for a default city so the app isn't empty
 */
window.addEventListener('DOMContentLoaded', function() {
  handleCitySearch('London');
});

/**
 * Allow users to click the error message to dismiss it
 */
errorMsg.addEventListener('click', function() {
  errorMsg.textContent = '';
});

Complete Weather App — All-in-One HTML File

For convenience, here is the entire weather app combined into a single HTML file. Simply replace YOUR_OPENWEATHERMAP_API_KEY_HERE with your actual API key, save the file as index.html, and open it in a browser.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>WeatherVue - Weather App</title>
  <style>
    /* === All CSS from the CSS section above goes here === */
    :root {
      --bg-primary: #1a1a2e;
      --bg-secondary: #16213e;
      --bg-card: #0f3460;
      --accent: #e94560;
      --text-primary: #ffffff;
      --text-secondary: #a0a0b0;
      --border-radius: 12px;
      --shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
    }

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

    body {
      font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
      background: linear-gradient(135deg, var(--bg-primary) 0%, var(--bg-secondary) 100%);
      color: var(--text-primary);
      min-height: 100vh;
      display: flex;
      justify-content: center;
      padding: 20px;
    }

    .container {
      max-width: 800px;
      width: 100%;
    }

    header {
      text-align: center;
      margin-bottom: 30px;
    }

    header h1 {
      font-size: 2.5rem;
      background: linear-gradient(135deg, #e94560, #ff6b6b);
      -webkit-background-clip: text;
      -webkit-text-fill-color: transparent;
      background-clip: text;
    }

    header p {
      color: var(--text-secondary);
      font-size: 1rem;
      margin-top: 8px;
    }

    .search-section {
      margin-bottom: 30px;
    }

    #searchForm {
      display: flex;
      gap: 10px;
    }

    #cityInput {
      flex: 1;
      padding: 14px 18px;
      border: 2px solid rgba(255, 255, 255, 0.1);
      border-radius: var(--border-radius);
      background: rgba(255, 255, 255, 0.05);
      color: var(--text-primary);
      font-size: 1rem;
      outline: none;
      transition: border-color 0.3s, box-shadow 0.3s;
    }

    #cityInput:focus {
      border-color: var(--accent);
      box-shadow: 0 0 0 3px rgba(233, 69, 96, 0.2);
    }

    #searchBtn {
      padding: 14px 24px;
      background: var(--accent);
      color: white;
      border: none;
      border-radius: var(--border-radius);
      font-size: 1rem;
      cursor: pointer;
      transition: transform 0.2s, box-shadow 0.2s;
      display: flex;
      align-items: center;
      gap: 6px;
    }

    #searchBtn:hover {
      transform: translateY(-2px);
      box-shadow: 0 4px 16px rgba(233, 69, 96, 0.4);
    }

    #searchBtn:active {
      transform: translateY(0);
    }

    .error-message {
      color: #ff6b6b;
      font-size: 0.9rem;
      margin-top: 8px;
      min-height: 20px;
      cursor: pointer;
    }

    .weather-display {
      min-height: 200px;
    }

    .current-weather-card {
      background: var(--bg-card);
      border-radius: var(--border-radius);
      padding: 30px;
      box-shadow: var(--shadow);
      animation: fadeIn 0.5s ease-in;
    }

    @keyframes fadeIn {
      from { opacity: 0; transform: translateY(20px); }
      to { opacity: 1; transform: translate

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles