API integration is one of the things developers do most and enjoy least. Parsing JSON, handling errors, managing loading states, formatting data for charts. It is repetitive work.
This is where Claude Code earns its keep. I gave it an API, a design goal, and a list of features. It handled the rest.
Total time: 2 hours 38 minutes. 6 prompts. The charts took the longest.
What We’re Building
A weather dashboard with:
- City search with autocomplete suggestions
- Current weather display with icon, temperature, humidity, wind speed
- 5-day forecast with daily high and low temperatures
- Hourly temperature chart using Chart.js
- Air quality index display
- Unit toggle between Celsius and Fahrenheit
- Recent searches saved in localStorage
- Auto-detect user location using the Geolocation API
- PWA support with offline cached last weather
Tech stack: React 19, Vite, OpenWeatherMap API, Chart.js, Tailwind CSS
Prerequisites
- Claude Code installed and configured
- Node.js 18+ installed
- An OpenWeatherMap API key (free tier — 60 calls/minute, 1,000 calls/day)
Getting the API Key
- Go to openweathermap.org and create a free account
- Navigate to “API Keys” in your profile
- Copy the default API key (or generate a new one)
- The free tier gives you access to: Current Weather, 5-Day Forecast, Air Pollution, and Geocoding APIs
The free tier is more than enough for a personal dashboard.
The Build Session
Total time: 2 hours 38 minutes. 6 prompts.
Prompt 1: Project Setup and Current Weather (Minute 0)
Create a React weather dashboard with Vite + Tailwind CSS. Features:
1. Search bar at the top — search by city name
2. Current weather card showing:
- City name and country
- Weather icon (from OpenWeatherMap)
- Temperature (large, prominent)
- "Feels like" temperature
- Humidity percentage
- Wind speed and direction
- Weather description (e.g., "scattered clouds")
3. Auto-detect user location on first load using Geolocation API
4. Loading skeleton while fetching data
5. Error handling for invalid cities and API errors
Use OpenWeatherMap free API. Store the API key in an environment variable.
Structure: src/components/, src/hooks/, src/services/, src/types/
What Claude generated:
A well-organized project:
weather-dashboard/
src/
components/
SearchBar.tsx
CurrentWeather.tsx
LoadingSkeleton.tsx
ErrorMessage.tsx
hooks/
useWeather.ts
useGeolocation.ts
services/
weatherApi.ts
types/
weather.ts
App.tsx
main.tsx
index.css
.env.example
package.json
vite.config.ts
tailwind.config.js
The weather API service:
const API_BASE = "https://api.openweathermap.org/data/2.5";
const GEO_BASE = "https://api.openweathermap.org/geo/1.0";
const API_KEY = import.meta.env.VITE_OWM_API_KEY;
export interface CurrentWeatherData {
name: string;
country: string;
temp: number;
feelsLike: number;
humidity: number;
windSpeed: number;
windDeg: number;
description: string;
icon: string;
dt: number;
}
export async function fetchCurrentWeather(
lat: number,
lon: number,
units: "metric" | "imperial" = "metric"
): Promise<CurrentWeatherData> {
const res = await fetch(
`${API_BASE}/weather?lat=${lat}&lon=${lon}&units=${units}&appid=${API_KEY}`
);
if (!res.ok) {
throw new Error(`Weather API error: ${res.status}`);
}
const data = await res.json();
return {
name: data.name,
country: data.sys.country,
temp: Math.round(data.main.temp),
feelsLike: Math.round(data.main.feels_like),
humidity: data.main.humidity,
windSpeed: data.wind.speed,
windDeg: data.wind.deg,
description: data.weather[0].description,
icon: data.weather[0].icon,
dt: data.dt,
};
}
export async function searchCity(query: string) {
const res = await fetch(
`${GEO_BASE}/direct?q=${encodeURIComponent(query)}&limit=5&appid=${API_KEY}`
);
if (!res.ok) throw new Error("Geocoding error");
return res.json();
}
The custom hook for weather:
import { useState, useEffect, useCallback } from "react";
import {
fetchCurrentWeather,
searchCity,
type CurrentWeatherData,
} from "../services/weatherApi";
export function useWeather() {
const [weather, setWeather] = useState<CurrentWeatherData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [units, setUnits] = useState<"metric" | "imperial">("metric");
const fetchByCoords = useCallback(
async (lat: number, lon: number) => {
setLoading(true);
setError(null);
try {
const data = await fetchCurrentWeather(lat, lon, units);
setWeather(data);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to fetch weather");
} finally {
setLoading(false);
}
},
[units]
);
const fetchByCity = useCallback(
async (cityName: string) => {
setLoading(true);
setError(null);
try {
const cities = await searchCity(cityName);
if (cities.length === 0) {
throw new Error(`City "${cityName}" not found`);
}
const { lat, lon } = cities[0];
const data = await fetchCurrentWeather(lat, lon, units);
setWeather(data);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to fetch weather");
} finally {
setLoading(false);
}
},
[units]
);
const toggleUnits = useCallback(() => {
setUnits((prev) => (prev === "metric" ? "imperial" : "metric"));
}, []);
return { weather, loading, error, units, fetchByCoords, fetchByCity, toggleUnits };
}
The current weather card:
interface Props {
weather: CurrentWeatherData;
units: "metric" | "imperial";
onToggleUnits: () => void;
}
export default function CurrentWeather({ weather, units, onToggleUnits }: Props) {
const tempUnit = units === "metric" ? "°C" : "°F";
const windUnit = units === "metric" ? "m/s" : "mph";
const windDirection = (deg: number) => {
const dirs = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"];
return dirs[Math.round(deg / 45) % 8];
};
return (
<div className="bg-white rounded-2xl shadow-sm border p-8">
<div className="flex justify-between items-start">
<div>
<h2 className="text-2xl font-bold text-gray-900">
{weather.name}, {weather.country}
</h2>
<p className="text-gray-500 capitalize mt-1">{weather.description}</p>
</div>
<button
onClick={onToggleUnits}
className="text-sm bg-gray-100 px-3 py-1 rounded-full hover:bg-gray-200 transition"
>
{units === "metric" ? "°F" : "°C"}
</button>
</div>
<div className="flex items-center gap-4 my-6">
<img
src={`https://openweathermap.org/img/wn/${weather.icon}@4x.png`}
alt={weather.description}
className="w-24 h-24"
/>
<span className="text-7xl font-light text-gray-900">
{weather.temp}{tempUnit}
</span>
</div>
<div className="grid grid-cols-3 gap-4">
<div className="bg-gray-50 rounded-xl p-4">
<p className="text-sm text-gray-500">Feels Like</p>
<p className="text-xl font-semibold">{weather.feelsLike}{tempUnit}</p>
</div>
<div className="bg-gray-50 rounded-xl p-4">
<p className="text-sm text-gray-500">Humidity</p>
<p className="text-xl font-semibold">{weather.humidity}%</p>
</div>
<div className="bg-gray-50 rounded-xl p-4">
<p className="text-sm text-gray-500">Wind</p>
<p className="text-xl font-semibold">
{weather.windSpeed} {windUnit} {windDirection(weather.windDeg)}
</p>
</div>
</div>
</div>
);
}
I set up the project:
npm install
echo "VITE_OWM_API_KEY=your_key_here" > .env
npm run dev
The app loaded. It asked for location permission. After granting it, the current weather for my location appeared. The layout was clean and the weather icon loaded from OpenWeatherMap’s CDN.
My review: Solid foundation. The hook pattern was clean and separated concerns well. The error handling covered the main cases. The component structure was reusable.
Time spent: 12 minutes
Prompt 2: 5-Day Forecast (Minute 12)
Add a 5-day forecast section below the current weather:
1. Fetch from OpenWeatherMap 5 day/3 hour forecast API
2. Group the 3-hour data by day
3. Show each day as a card with: day name, weather icon, high temp, low temp
4. Show the 5 day cards in a horizontal row (scrollable on mobile)
5. The forecast should update when the city changes or units toggle
What Claude generated:
A new API function and forecast component:
export interface ForecastDay {
date: string;
dayName: string;
icon: string;
description: string;
tempMax: number;
tempMin: number;
}
export async function fetchForecast(
lat: number,
lon: number,
units: "metric" | "imperial" = "metric"
): Promise<ForecastDay[]> {
const res = await fetch(
`${API_BASE}/forecast?lat=${lat}&lon=${lon}&units=${units}&appid=${API_KEY}`
);
if (!res.ok) throw new Error("Forecast API error");
const data = await res.json();
// Group by day and find min/max temps
const days = new Map<string, { temps: number[]; icons: string[]; descriptions: string[] }>();
for (const item of data.list) {
const date = new Date(item.dt * 1000).toLocaleDateString("en-US", {
weekday: "short",
month: "short",
day: "numeric",
});
if (!days.has(date)) {
days.set(date, { temps: [], icons: [], descriptions: [] });
}
const day = days.get(date)!;
day.temps.push(item.main.temp);
day.icons.push(item.weather[0].icon);
day.descriptions.push(item.weather[0].description);
}
return Array.from(days.entries())
.slice(0, 5)
.map(([date, { temps, icons, descriptions }]) => ({
date,
dayName: date.split(",")[0],
icon: icons[Math.floor(icons.length / 2)], // midday icon
description: descriptions[Math.floor(descriptions.length / 2)],
tempMax: Math.round(Math.max(...temps)),
tempMin: Math.round(Math.min(...temps)),
}));
}
The forecast display:
export default function Forecast({ forecast, units }: Props) {
const tempUnit = units === "metric" ? "°C" : "°F";
return (
<div className="mt-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">5-Day Forecast</h3>
<div className="flex gap-3 overflow-x-auto pb-2">
{forecast.map((day) => (
<div
key={day.date}
className="flex-shrink-0 bg-white rounded-xl border p-4 text-center min-w-[120px]"
>
<p className="text-sm font-medium text-gray-600">{day.dayName}</p>
<img
src={`https://openweathermap.org/img/wn/${day.icon}@2x.png`}
alt={day.description}
className="w-12 h-12 mx-auto"
/>
<p className="text-sm capitalize text-gray-500">{day.description}</p>
<div className="mt-2 flex justify-center gap-2 text-sm">
<span className="font-semibold">{day.tempMax}{tempUnit}</span>
<span className="text-gray-400">{day.tempMin}{tempUnit}</span>
</div>
</div>
))}
</div>
</div>
);
}
My review: The grouping logic was smart. The OpenWeatherMap free tier returns 3-hour intervals, so you get 8 data points per day. Claude correctly grouped them by date, took the min/max temps, and picked the midday icon as representative.
The horizontal scrollable layout worked well on mobile.
Time spent: 8 minutes
Prompt 3: Hourly Temperature Chart (Minute 20)
Add an hourly temperature chart below the forecast:
1. Use Chart.js with react-chartjs-2
2. Show next 24 hours of temperature data (8 data points from the 3-hour API)
3. Line chart with gradient fill below the line
4. X axis: time labels (e.g., "3 PM", "6 PM")
5. Y axis: temperature with unit
6. Tooltip showing exact temperature and weather description
7. Clean, minimal design — no grid lines, thin line
What Claude generated:
import { Line } from "react-chartjs-2";
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Filler,
Tooltip,
} from "chart.js";
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Filler, Tooltip);
interface HourlyDataPoint {
time: string;
temp: number;
description: string;
}
interface Props {
hourlyData: HourlyDataPoint[];
units: "metric" | "imperial";
}
export default function HourlyChart({ hourlyData, units }: Props) {
const tempUnit = units === "metric" ? "°C" : "°F";
const data = {
labels: hourlyData.map((d) => d.time),
datasets: [
{
data: hourlyData.map((d) => d.temp),
borderColor: "#3b82f6",
backgroundColor: (context: any) => {
const ctx = context.chart.ctx;
const gradient = ctx.createLinearGradient(0, 0, 0, 200);
gradient.addColorStop(0, "rgba(59, 130, 246, 0.3)");
gradient.addColorStop(1, "rgba(59, 130, 246, 0)");
return gradient;
},
fill: true,
tension: 0.4,
borderWidth: 2,
pointRadius: 4,
pointBackgroundColor: "#3b82f6",
pointBorderColor: "#ffffff",
pointBorderWidth: 2,
},
],
};
const options = {
responsive: true,
maintainAspectRatio: false,
plugins: {
tooltip: {
callbacks: {
label: (context: any) => {
const point = hourlyData[context.dataIndex];
return `${point.temp}${tempUnit} — ${point.description}`;
},
},
},
},
scales: {
x: {
grid: { display: false },
ticks: { color: "#9ca3af", font: { size: 12 } },
},
y: {
grid: { display: false },
ticks: {
color: "#9ca3af",
font: { size: 12 },
callback: (value: any) => `${value}${tempUnit}`,
},
},
},
};
return (
<div className="mt-6 bg-white rounded-2xl border p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">
Next 24 Hours
</h3>
<div className="h-[200px]">
<Line data={data} options={options} />
</div>
</div>
);
}
Problem: The gradient fill did not work on the first render. Chart.js needs the canvas context to create gradients, but react-chartjs-2 creates the chart before the context is available on initial render.
I asked Claude to fix it:
The chart gradient does not render on first load. The canvas context is
not available when the data object is created. Use a ref to get the
canvas context after mount and update the gradient.
Claude fixed it with a useEffect and chart ref. After the fix, the gradient fill appeared on the first render.
Second problem: The time labels showed in 24-hour format (like “15:00”) instead of “3 PM”. Claude had used toLocaleTimeString() without specifying the format. I asked for a fix:
Show time labels in 12-hour format like "3 PM" instead of "15:00:00".
const time = new Date(item.dt * 1000).toLocaleTimeString("en-US", {
hour: "numeric",
hour12: true,
});
After these two fixes, the chart looked clean with a nice blue gradient, smooth curve, and readable time labels.
Time spent: 22 minutes (chart issues took the longest)
Prompt 4: Air Quality Index (Minute 42)
Add an air quality section:
1. Fetch from OpenWeatherMap Air Pollution API
2. Show AQI (1-5 scale) with a color-coded badge:
1=Good (green), 2=Fair (yellow), 3=Moderate (orange),
4=Poor (red), 5=Very Poor (purple)
3. Show main pollutants: PM2.5, PM10, NO2, O3 with progress bars
4. Place this section next to the current weather card on desktop (grid layout)
What Claude generated:
export interface AirQualityData {
aqi: number;
aqiLabel: string;
components: {
pm2_5: number;
pm10: number;
no2: number;
o3: number;
};
}
const AQI_LABELS = ["", "Good", "Fair", "Moderate", "Poor", "Very Poor"];
const AQI_COLORS = ["", "#22c55e", "#eab308", "#f97316", "#ef4444", "#a855f7"];
export async function fetchAirQuality(lat: number, lon: number): Promise<AirQualityData> {
const res = await fetch(
`${API_BASE}/air_pollution?lat=${lat}&lon=${lon}&appid=${API_KEY}`
);
if (!res.ok) throw new Error("Air quality API error");
const data = await res.json();
const item = data.list[0];
return {
aqi: item.main.aqi,
aqiLabel: AQI_LABELS[item.main.aqi],
components: {
pm2_5: item.components.pm2_5,
pm10: item.components.pm10,
no2: item.components.no2,
o3: item.components.o3,
},
};
}
The air quality component:
export default function AirQuality({ data }: { data: AirQualityData }) {
const aqiColors = ["", "bg-green-500", "bg-yellow-500", "bg-orange-500", "bg-red-500", "bg-purple-500"];
const color = aqiColors[data.aqi];
const pollutants = [
{ name: "PM2.5", value: data.components.pm2_5, max: 75, unit: "ug/m3" },
{ name: "PM10", value: data.components.pm10, max: 150, unit: "ug/m3" },
{ name: "NO2", value: data.components.no2, max: 200, unit: "ug/m3" },
{ name: "O3", value: data.components.o3, max: 180, unit: "ug/m3" },
];
return (
<div className="bg-white rounded-2xl border p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Air Quality</h3>
<div className="flex items-center gap-3 mb-6">
<span className={`${color} text-white px-3 py-1 rounded-full text-sm font-medium`}>
{data.aqi}
</span>
<span className="text-gray-700 font-medium">{data.aqiLabel}</span>
</div>
<div className="space-y-3">
{pollutants.map((p) => (
<div key={p.name}>
<div className="flex justify-between text-sm mb-1">
<span className="text-gray-600">{p.name}</span>
<span className="text-gray-900 font-medium">
{p.value.toFixed(1)} {p.unit}
</span>
</div>
<div className="h-2 bg-gray-100 rounded-full overflow-hidden">
<div
className={`h-full ${color} rounded-full transition-all duration-500`}
style={{ width: `${Math.min((p.value / p.max) * 100, 100)}%` }}
/>
</div>
</div>
))}
</div>
</div>
);
}
My review: The AQI color mapping was correct per OpenWeatherMap’s documentation. The progress bars gave a quick visual sense of pollution levels. The max values for each pollutant came from WHO guidelines.
One small issue: Claude hardcoded the unit as “ug/m3” instead of the proper symbol “μg/m³”. A tiny detail, but it matters for polish.
Time spent: 10 minutes
Prompt 5: Recent Searches and localStorage (Minute 52)
Add recent searches:
1. When a user searches for a city, save it to localStorage (max 5 recent)
2. Show recent searches as clickable chips below the search bar
3. Clicking a chip searches for that city again
4. Add a small X button to remove a search from recents
5. Also save the last weather data to localStorage for offline fallback
What Claude generated:
A custom hook for recent searches:
const STORAGE_KEY = "weather-recent-searches";
const MAX_RECENT = 5;
export function useRecentSearches() {
const [recents, setRecents] = useState<string[]>(() => {
try {
const saved = localStorage.getItem(STORAGE_KEY);
return saved ? JSON.parse(saved) : [];
} catch {
return [];
}
});
const addSearch = useCallback((city: string) => {
setRecents((prev) => {
const filtered = prev.filter((s) => s.toLowerCase() !== city.toLowerCase());
const updated = [city, ...filtered].slice(0, MAX_RECENT);
localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
return updated;
});
}, []);
const removeSearch = useCallback((city: string) => {
setRecents((prev) => {
const updated = prev.filter((s) => s !== city);
localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
return updated;
});
}, []);
return { recents, addSearch, removeSearch };
}
The recent searches display:
{recents.length > 0 && (
<div className="flex flex-wrap gap-2 mt-3">
{recents.map((city) => (
<div
key={city}
className="flex items-center gap-1 bg-gray-100 rounded-full px-3 py-1 text-sm"
>
<button
onClick={() => onSearch(city)}
className="text-gray-700 hover:text-gray-900"
>
{city}
</button>
<button
onClick={() => removeSearch(city)}
className="text-gray-400 hover:text-red-500 ml-1"
>
x
</button>
</div>
))}
</div>
)}
Claude also added offline caching:
const WEATHER_CACHE_KEY = "weather-last-data";
// After successful fetch
localStorage.setItem(WEATHER_CACHE_KEY, JSON.stringify({
weather: data,
forecast: forecastData,
airQuality: aqData,
timestamp: Date.now(),
}));
// On app load, if network fails
const cached = localStorage.getItem(WEATHER_CACHE_KEY);
if (cached) {
const { weather, forecast, airQuality, timestamp } = JSON.parse(cached);
const age = Date.now() - timestamp;
if (age < 3600000) { // 1 hour
// Use cached data
}
}
Time spent: 10 minutes
Prompt 6: PWA and Final Polish (Minute 62)
Make it a PWA:
1. Add a service worker that caches the app shell and last API response
2. Add manifest.json with app name "WeatherFlow", blue theme color
3. Add install prompt handling
4. Add a "Last updated" timestamp on the weather display
5. Final polish: add a gradient background that changes with weather
(blue for clear, gray for cloudy, dark for night)
What Claude generated:
Claude created a service worker using Workbox (via vite-plugin-pwa):
// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { VitePWA } from "vite-plugin-pwa";
export default defineConfig({
plugins: [
react(),
VitePWA({
registerType: "autoUpdate",
manifest: {
name: "WeatherFlow",
short_name: "Weather",
description: "Real-time weather dashboard",
theme_color: "#3b82f6",
background_color: "#f9fafb",
display: "standalone",
icons: [
{ src: "/icon-192.png", sizes: "192x192", type: "image/png" },
{ src: "/icon-512.png", sizes: "512x512", type: "image/png" },
],
},
workbox: {
runtimeCaching: [
{
urlPattern: /^https:\/\/api\.openweathermap\.org/,
handler: "NetworkFirst",
options: {
cacheName: "weather-api",
expiration: { maxEntries: 10, maxAgeSeconds: 3600 },
},
},
{
urlPattern: /^https:\/\/openweathermap\.org\/img/,
handler: "CacheFirst",
options: {
cacheName: "weather-icons",
expiration: { maxEntries: 50, maxAgeSeconds: 86400 },
},
},
],
},
}),
],
});
The dynamic background gradient:
function getBackgroundGradient(icon: string): string {
// OpenWeatherMap icon codes: d=day, n=night
const isNight = icon.endsWith("n");
if (isNight) return "from-gray-900 via-indigo-900 to-gray-900";
// Clear
if (icon.startsWith("01")) return "from-blue-400 via-blue-300 to-cyan-200";
// Few/scattered clouds
if (icon.startsWith("02") || icon.startsWith("03"))
return "from-blue-300 via-gray-200 to-blue-100";
// Broken/overcast clouds
if (icon.startsWith("04")) return "from-gray-400 via-gray-300 to-gray-200";
// Rain
if (icon.startsWith("09") || icon.startsWith("10"))
return "from-gray-500 via-gray-400 to-blue-300";
// Thunderstorm
if (icon.startsWith("11")) return "from-gray-700 via-purple-600 to-gray-500";
// Snow
if (icon.startsWith("13")) return "from-blue-100 via-white to-gray-100";
return "from-blue-400 via-blue-300 to-cyan-200";
}
Problem: The PWA icons did not exist. Claude referenced /icon-192.png and /icon-512.png but did not create them. I had to generate simple weather icons manually and put them in the public/ folder.
Also, the dynamic background gradient only worked for the body. The white card components looked out of place on a dark night background. I asked Claude to adjust the card styles for dark backgrounds:
When the background is dark (night time), make the cards semi-transparent
with white text instead of white cards with dark text. Use a backdrop-blur
effect.
Claude added conditional card styling based on whether it was night or day. The final result looked polished.
Time spent: 24 minutes
The remaining time (about 1 hour 12 minutes) was spent on:
- Testing with different cities around the world (Tokyo, Sydney, Reykjavik — to test edge cases)
- Fixing the unit toggle — changing units did not refetch the forecast (only current weather)
- The search autocomplete was not debounced — it fired on every keystroke, hitting the API too fast
- Adding proper error states when the API key was missing or invalid
- Deploying to Vercel (straightforward — no server-side code)
- Lighthouse audit and fixing the missing icon sizes issue
Total time: 2 hours 38 minutes.
What Went Right
- API integration was smooth. Claude correctly used all four OpenWeatherMap endpoints (current, forecast, air pollution, geocoding) and parsed the responses into clean TypeScript types.
- The custom hooks pattern was excellent.
useWeather,useGeolocation, anduseRecentSearcheskept the components clean and the logic reusable. - Chart.js integration worked. After the gradient fix, the chart was smooth, responsive, and looked professional.
- PWA setup was nearly complete. Using
vite-plugin-pwawas the right choice — much simpler than manual service worker configuration. - Dynamic backgrounds added real polish. The gradient changing with weather conditions made the dashboard feel like a real product.
What Went Wrong
1. Chart.js Gradient Required Two Fixes
The gradient fill did not work on first render because Chart.js needs the canvas context. Then the time format was wrong. Chart customization is always finicky — AI or not.
Lesson: Chart libraries have quirks that require hands-on debugging. Do not expect charts to work perfectly on the first prompt.
2. Unit Toggle Did Not Refetch All Data
Toggling Celsius/Fahrenheit only re-fetched current weather, not the forecast or hourly chart. This was a state management bug — the useEffect dependency array was incomplete.
Lesson: When building features that affect multiple components, check that all dependent data updates. Ask Claude: “When units change, refetch ALL weather data.”
3. Search Hit API Too Fast
The city search fired on every keystroke, using up API calls quickly. The free tier only allows 60 calls per minute. Three quick searches could trigger rate limiting.
Lesson: Always add debouncing to search inputs that hit external APIs. Include “debounce search input with 300ms delay” in your prompt.
4. No PWA Icons
Claude generated the manifest referencing icons that did not exist. The PWA install failed without them.
Lesson: AI generates code references but cannot create binary assets (images, icons, fonts). Keep a checklist of required static assets.
The Final Result
A deployed weather dashboard with:
- City search with recent searches
- Current weather with dynamic background
- 5-day forecast cards
- Hourly temperature chart
- Air quality index with pollutant bars
- Celsius/Fahrenheit toggle
- PWA with offline support
- Responsive on mobile
To run it yourself:
git clone https://github.com/kemalcodes/vibe-coding-projects.git
cd vibe-coding-projects
git checkout weather-dashboard
npm install
echo "VITE_OWM_API_KEY=your_api_key" > .env
npm run dev
Time and Cost
- Total time: 2 hours 38 minutes
- Claude prompts: 6 (plus 4 fixes)
- Estimated token usage: ~55,000 tokens
- Hosting cost: $0/month (Vercel free tier)
- API cost: $0 (OpenWeatherMap free tier)
- Dependencies: React, Chart.js, Tailwind, vite-plugin-pwa
Lessons Learned
1. API-first projects are a sweet spot for vibe coding. Most of the work is parsing JSON, formatting data, and building UI. Claude handles all of this extremely well.
2. External APIs have quirks AI cannot know. Rate limits, unreliable endpoints, response format changes — these require human testing and knowledge of the specific API.
3. Charts always need manual tweaking. No matter the library, chart configuration is tedious. Claude gets 80% right, but the visual polish takes human iteration.
4. PWAs add 30 minutes. Service workers, manifest, icons, install prompts — it is not difficult, but it adds up. Budget time for it.
5. Dynamic styling makes a huge difference. The weather-based background gradient took 10 minutes to add but made the dashboard feel 10x more polished. Ask for it.
Source Code
Full source code: kemalcodes/vibe-coding-projects (branch: weather-dashboard)
Related Articles
- Build a Full-Stack Blog with Claude — Previous article
- Build a CLI Todo App with Claude — First article in this series
- Claude Code Mastery — Advanced Claude Code patterns
What’s Next?
Next, we build a Markdown Editor with Claude — Electron + Live Preview. A desktop application. Electron, CodeMirror, file system access. It gets interesting when Claude has to handle the main process and renderer process split.