What you'll build
By the end of this tutorial you'll have a weather app running in your browser: a search box where you type a city, the current conditions for that city (temperature, what it feels like, humidity, and wind), and a 5-day forecast row with a high and a low for each day. The data is live, pulled from a real public API each time you search.
Every file you need is on this page. Paste each one into a fresh Vite project, follow the setup steps, and it runs. There is no repo to clone, no .env file, and no API key to sign up for. The tutorial also explains the decisions behind the code, especially how to handle the three states every app that talks to a network has to handle: loading, error, and success.
Run it live in your browser
No install, no API keyLive sandbox powered by StackBlitz. It runs the same files you copy below, so what you edit here matches the walkthrough.
Why Open-Meteo (and no API key)
Most weather tutorials send you to OpenWeatherMap, which means creating an account, generating an API key, waiting for it to activate, and hiding it so it doesn't leak in your frontend bundle. That is real friction for something you just want to learn on. Open-Meteo skips all of it: it is a free weather API with no key and no signup, and it is generous enough for a learning project or a small site (free for non-commercial use, with paid plans if you go big). You call it straight from the browser.
Here is the one honest trade-off. Because there is no key, there is nothing stopping you from calling it too often, and a real production app would still put a small cache or a tiny backend in front of it to be a good citizen and to keep from hitting the fair-use limits. For learning the React and fetch patterns, calling it directly from the client is exactly right, and it is the fastest path to a working app.
Setup
Scaffold a fresh React + TypeScript project with Vite. This app needs no extra runtime library at all, so after the scaffold you just install what Vite already listed and start the dev server:
npm create vite@latest weather-app -- --template react-ts
cd weather-app
npm install
npm run devnpm create vite scaffolds the project, including react, react-dom, typescript, vite, and @vitejs/plugin-react in package.json. You add nothing on top: the networking is done by fetch, which is built into every modern browser, so there is no axios or other HTTP client to install. Vite's template gives you a working package.json; the scripts block looks like this:
{
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
}
}npm run dev starts the dev server (usually at http://localhost:5173) with hot reload. npm run build type-checks with tsc and produces a static bundle in dist/ you can deploy anywhere. Every file below goes in the src/ directory Vite created. The template's src/main.tsx already renders <App />, so you don't touch it: you are replacing App.tsx and adding a few new files next to it.
Define the data types
Start with the shape of the data, so the compiler catches a typo the moment you make one. The app deals with four things: a place (the result of turning a city name into coordinates), the current conditions, one day of the forecast, and a Weather object that bundles them together. Create src/types.ts:
export interface Place {
name: string;
country: string;
latitude: number;
longitude: number;
}
export interface CurrentConditions {
temperature: number;
apparentTemperature: number;
humidity: number;
windSpeed: number;
weatherCode: number;
}
export interface DailyForecast {
date: string;
weatherCode: number;
tempMax: number;
tempMin: number;
}
export interface Weather {
place: Place;
current: CurrentConditions;
units: { temperature: string; windSpeed: string };
daily: DailyForecast[];
}These are your app's shapes, not the API's. The API returns fields like temperature_2m and weather_code; you map those into these cleaner names in one place (the API module below), so the rest of the app never sees the raw response format. The weatherCode is a number Open-Meteo uses to say what the sky is doing (0 is clear, 61 is rain, 95 is a thunderstorm, and so on); the next file turns that number into a label and an icon.
Map weather codes to something human
Open-Meteo reports the sky with a WMO weather code, a small integer. A lookup table turns that integer into a readable label and an emoji icon. Create src/weatherCodes.ts:
interface WeatherInfo {
label: string;
icon: string;
}
const WEATHER_CODES: Record<number, WeatherInfo> = {
0: { label: "Clear sky", icon: "☀️" },
1: { label: "Mainly clear", icon: "🌤️" },
2: { label: "Partly cloudy", icon: "⛅" },
3: { label: "Overcast", icon: "☁️" },
45: { label: "Fog", icon: "🌫️" },
48: { label: "Rime fog", icon: "🌫️" },
51: { label: "Light drizzle", icon: "🌦️" },
53: { label: "Drizzle", icon: "🌦️" },
55: { label: "Heavy drizzle", icon: "🌦️" },
61: { label: "Light rain", icon: "🌧️" },
63: { label: "Rain", icon: "🌧️" },
65: { label: "Heavy rain", icon: "🌧️" },
71: { label: "Light snow", icon: "🌨️" },
73: { label: "Snow", icon: "🌨️" },
75: { label: "Heavy snow", icon: "❄️" },
80: { label: "Rain showers", icon: "🌦️" },
81: { label: "Rain showers", icon: "🌧️" },
82: { label: "Violent rain showers", icon: "⛈️" },
95: { label: "Thunderstorm", icon: "⛈️" },
96: { label: "Thunderstorm with hail", icon: "⛈️" },
99: { label: "Thunderstorm with hail", icon: "⛈️" },
};
export function describeWeather(code: number): WeatherInfo {
return WEATHER_CODES[code] ?? { label: "Unknown", icon: "❓" };
}describeWeather takes a code and always returns something safe: if Open-Meteo ever sends a code you didn't map, the ?? { label: "Unknown", ... } fallback keeps the app from rendering a blank. That nullish-coalescing default is the small habit that stops one surprise value from breaking the whole card.
The API module: two calls, typed
This is the core of the app. Getting weather for a city is two requests, not one. First a geocoding call turns "Lisbon" into a latitude and longitude, because the forecast endpoint speaks coordinates, not names. Then the forecast call returns the current conditions and the daily forecast for those coordinates. We type both responses, then map them into the clean shapes from types.ts. Create src/api.ts:
import type { Place, Weather } from "./types";
const GEOCODE_URL = "https://geocoding-api.open-meteo.com/v1/search";
const FORECAST_URL = "https://api.open-meteo.com/v1/forecast";
interface GeoResult {
name: string;
country: string;
latitude: number;
longitude: number;
admin1?: string;
}
async function geocodeCity(query: string): Promise<Place> {
const url = `${GEOCODE_URL}?name=${encodeURIComponent(query)}&count=1&language=en`;
const res = await fetch(url);
if (!res.ok) throw new Error("Could not reach the geocoding service.");
const data: { results?: GeoResult[] } = await res.json();
const match = data.results?.[0];
if (!match) throw new Error(`No place found for "${query}". Check the spelling.`);
return {
name: match.admin1 ? `${match.name}, ${match.admin1}` : match.name,
country: match.country,
latitude: match.latitude,
longitude: match.longitude,
};
}
interface ForecastResponse {
current: {
temperature_2m: number;
apparent_temperature: number;
relative_humidity_2m: number;
wind_speed_10m: number;
weather_code: number;
};
current_units: { temperature_2m: string; wind_speed_10m: string };
daily: {
time: string[];
weather_code: number[];
temperature_2m_max: number[];
temperature_2m_min: number[];
};
}
export async function getWeather(query: string): Promise<Weather> {
const place = await geocodeCity(query);
const params = new URLSearchParams({
latitude: String(place.latitude),
longitude: String(place.longitude),
current:
"temperature_2m,apparent_temperature,relative_humidity_2m,wind_speed_10m,weather_code",
daily: "weather_code,temperature_2m_max,temperature_2m_min",
timezone: "auto",
forecast_days: "5",
});
const res = await fetch(`${FORECAST_URL}?${params}`);
if (!res.ok) throw new Error("Could not load the forecast. Try again.");
const data: ForecastResponse = await res.json();
return {
place,
current: {
temperature: data.current.temperature_2m,
apparentTemperature: data.current.apparent_temperature,
humidity: data.current.relative_humidity_2m,
windSpeed: data.current.wind_speed_10m,
weatherCode: data.current.weather_code,
},
units: {
temperature: data.current_units.temperature_2m,
windSpeed: data.current_units.wind_speed_10m,
},
daily: data.daily.time.map((date, i) => ({
date,
weatherCode: data.daily.weather_code[i],
tempMax: data.daily.temperature_2m_max[i],
tempMin: data.daily.temperature_2m_min[i],
})),
};
}Two things here are worth slowing down on. First, encodeURIComponent(query) on the city name: a city like "São Paulo" has a space and an accent, and encoding it keeps the URL valid. Second, the daily forecast comes back as parallel arrays, not a list of objects: Open-Meteo gives you daily.time, daily.temperature_2m_max, and daily.temperature_2m_min as separate arrays where index i lines up across all of them. The .map((date, i) => ...) walks the dates and pulls the matching entry from each of the other arrays by index, turning three flat arrays into one tidy list of DailyForecast objects. Every throw new Error(...) here becomes a message the UI can show, which is the whole point of throwing instead of returning null: the component gets a real reason it can display.
The current conditions card
Now the presentational pieces. CurrentWeather takes a Weather object and shows the headline: the place, a big temperature, the condition, and a small stats row. It holds no state. Create src/CurrentWeather.tsx:
import type { Weather } from "./types";
import { describeWeather } from "./weatherCodes";
interface CurrentWeatherProps {
weather: Weather;
}
export function CurrentWeather({ weather }: CurrentWeatherProps) {
const { place, current, units } = weather;
const conditions = describeWeather(current.weatherCode);
return (
<section className="current">
<div className="current-head">
<h2>{place.name}</h2>
<span className="country">{place.country}</span>
</div>
<div className="current-main">
<span className="current-icon" role="img" aria-label={conditions.label}>
{conditions.icon}
</span>
<span className="current-temp">
{Math.round(current.temperature)}
{units.temperature}
</span>
</div>
<p className="current-desc">{conditions.label}</p>
<dl className="current-stats">
<div>
<dt>Feels like</dt>
<dd>
{Math.round(current.apparentTemperature)}
{units.temperature}
</dd>
</div>
<div>
<dt>Humidity</dt>
<dd>{current.humidity}%</dd>
</div>
<div>
<dt>Wind</dt>
<dd>
{Math.round(current.windSpeed)} {units.windSpeed}
</dd>
</div>
</dl>
</section>
);
}Math.round is doing quiet work: the API returns temperatures like 18.3, and nobody wants to read a decimal on a weather card, so we round for display only (the real value stays in state if you ever need it). The icon gets role="img" and an aria-label of the condition text, so a screen reader announces "Partly cloudy" instead of trying to read an emoji. Small accessibility touch, no extra library.
The 5-day forecast row
The forecast is a row of small cards, one per day, each with a weekday, an icon, and the high and low. Create src/Forecast.tsx:
import type { DailyForecast } from "./types";
import { describeWeather } from "./weatherCodes";
interface ForecastProps {
days: DailyForecast[];
}
function weekday(isoDate: string): string {
return new Date(`${isoDate}T00:00`).toLocaleDateString("en-US", {
weekday: "short",
});
}
export function Forecast({ days }: ForecastProps) {
return (
<section className="forecast">
<h3>5-day forecast</h3>
<ul className="forecast-row">
{days.map((day) => {
const conditions = describeWeather(day.weatherCode);
return (
<li key={day.date} className="forecast-day">
<span className="forecast-name">{weekday(day.date)}</span>
<span
className="forecast-icon"
role="img"
aria-label={conditions.label}
>
{conditions.icon}
</span>
<span className="forecast-temps">
<strong>{Math.round(day.tempMax)}°</strong>{" "}
{Math.round(day.tempMin)}°
</span>
</li>
);
})}
</ul>
</section>
);
}Two details earn their keep. key={day.date} gives React a stable identity for each day so it can update the list efficiently, and the date string (like 2026-08-17) is unique per row, which is exactly what a key should be. And weekday builds the date as `${isoDate}T00:00, adding the time so JavaScript reads it as local midnight rather than UTC midnight; without the T00:00, a plain new Date("2026-08-17")` is parsed as UTC and can show the wrong weekday for anyone west of Greenwich. It is a classic off-by-one-day bug, and one string fixes it.
Shore up your React first
If the components and props here felt shaky, the fastest free way to solidify React is Scrimba's interactive Learn React course, where you edit components inside the lesson. For the ranked rundown of every free option, see our best free React courses guide, and keep the React cheat sheet open while you build.
Wire it up in App.tsx
App is where the state lives and the three states get handled. It tracks the search text, the current status (idle, loading, success, or error), the weather when it arrives, and an error message when something goes wrong. On submit it calls getWeather and moves the status along. Replace the generated src/App.tsx with this:
import { useState, type FormEvent } from "react";
import { getWeather } from "./api";
import type { Weather } from "./types";
import { CurrentWeather } from "./CurrentWeather";
import { Forecast } from "./Forecast";
import "./App.css";
type Status = "idle" | "loading" | "success" | "error";
export default function App() {
const [query, setQuery] = useState("");
const [status, setStatus] = useState<Status>("idle");
const [weather, setWeather] = useState<Weather | null>(null);
const [error, setError] = useState("");
async function handleSubmit(event: FormEvent) {
event.preventDefault();
const city = query.trim();
if (!city) return;
setStatus("loading");
setError("");
try {
const result = await getWeather(city);
setWeather(result);
setStatus("success");
} catch (err) {
setError(err instanceof Error ? err.message : "Something went wrong.");
setStatus("error");
}
}
return (
<main className="app">
<h1>Weather</h1>
<form className="search" onSubmit={handleSubmit}>
<input
type="text"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search a city, e.g. Lisbon"
aria-label="City name"
/>
<button type="submit" disabled={status === "loading"}>
{status === "loading" ? "Loading..." : "Search"}
</button>
</form>
{status === "idle" && (
<p className="message">Search a city to see its weather.</p>
)}
{status === "error" && <p className="message error">{error}</p>}
{status === "success" && weather && (
<>
<CurrentWeather weather={weather} />
<Forecast days={weather.daily} />
</>
)}
</main>
);
}The status union is the backbone. Modeling it as one of four named strings (rather than a couple of loose booleans like isLoading and hasError) means the UI can never be in a contradictory state such as loading and error at once, and the JSX reads as a plain list of "in this state, show this." The catch block narrows the error with err instanceof Error before reading .message, because in TypeScript a caught value is unknown, so that check is how you safely pull the message the API module threw. The button is disabled while loading, which stops a double submit, and trimming the input plus the if (!city) return guard stops an empty search from firing a request.
The import "./App.css" line pulls in the layout. Create or overwrite src/App.css:
.app {
max-width: 560px;
margin: 0 auto;
padding: 24px;
font-family: system-ui, sans-serif;
color: #1f2937;
}
.app h1 {
margin: 0 0 16px;
}
.search {
display: flex;
gap: 8px;
margin-bottom: 24px;
}
.search input {
flex: 1;
padding: 10px 12px;
font-size: 15px;
border: 1px solid #d1d5db;
border-radius: 8px;
}
.search button {
padding: 10px 16px;
font-size: 15px;
font-weight: 600;
color: #fff;
background: #2563eb;
border: none;
border-radius: 8px;
cursor: pointer;
}
.search button:disabled {
opacity: 0.6;
cursor: default;
}
.message {
color: #6b7280;
}
.message.error {
color: #b91c1c;
}
.current {
padding: 20px;
border: 1px solid #e5e7eb;
border-radius: 12px;
background: #fff;
margin-bottom: 20px;
}
.current-head {
display: flex;
align-items: baseline;
gap: 8px;
}
.current-head h2 {
margin: 0;
}
.country {
color: #6b7280;
font-size: 14px;
}
.current-main {
display: flex;
align-items: center;
gap: 12px;
margin: 8px 0 4px;
}
.current-icon {
font-size: 44px;
}
.current-temp {
font-size: 44px;
font-weight: 600;
}
.current-desc {
margin: 0 0 16px;
color: #374151;
}
.current-stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
margin: 0;
}
.current-stats dt {
font-size: 12px;
color: #6b7280;
}
.current-stats dd {
margin: 2px 0 0;
font-size: 16px;
font-weight: 600;
}
.forecast h3 {
margin: 0 0 12px;
font-size: 15px;
}
.forecast-row {
list-style: none;
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 8px;
padding: 0;
margin: 0;
}
.forecast-day {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 12px 4px;
border: 1px solid #e5e7eb;
border-radius: 10px;
background: #fff;
}
.forecast-name {
font-size: 13px;
color: #6b7280;
}
.forecast-icon {
font-size: 24px;
}
.forecast-temps {
font-size: 13px;
color: #6b7280;
}
.forecast-temps strong {
color: #1f2937;
}Run it
Start the dev server if it is not already running:
npm run devOpen the local URL Vite prints (usually http://localhost:5173) and check each piece:
- The page loads with the prompt "Search a city to see its weather." That is the idle state.
- Type a city (try
Lisbon,Tokyo, orNew York) and hit Search. The button readsLoading...for a moment, then the current conditions card and the 5-day forecast appear. - The card shows the place, a rounded temperature with the right unit, the condition with an icon, and feels-like, humidity, and wind below it.
- Search a nonsense string like
asdfgh. You get the error message "No place found..." instead of a broken page, because the geocoding call returned no match and the app caught it. - Turn off your network and search. You get a fetch error message, not a blank screen, because the try/catch handles it.
If the current conditions show but the forecast row is empty, check that your forecast URL includes the daily=... parameter; without it, Open-Meteo returns no daily block and the map has nothing to walk. If nothing happens on submit, confirm the form's onSubmit is on the <form> (not the button) so pressing Enter works too.
Where to go next
Be clear about what this tutorial did and didn't cover. It taught the pattern that every data-driven React app is built on: fetch from a typed API, map the response into your own shapes, and render loading, error, and success states honestly. It deliberately did not add caching, so every search is a fresh pair of requests; a real app would cache recent cities or put a small backend in front of the API. That is the honest limitation, and it keeps the scope on the React and fetch skills that transfer to any API, not just this one.
Three next steps build on this without a rewrite. Use the browser's location. Call navigator.geolocation.getCurrentPosition to get the user's latitude and longitude and skip the geocoding step, showing their local weather on load. Cache recent searches. Save the last few cities to localStorage and render them as quick buttons; our Markdown notes tutorial shows the same localStorage pattern. Chart the forecast. Feed the daily highs and lows into a line chart with the approach from our data dashboard tutorial.
For the full sequenced route into frontend work, see /learn/frontend.
Frequently asked questions
Do I need an API key for this weather app?
No. Open-Meteo is a free weather API that needs no key and no signup, which is why this tutorial can be paste-and-run. You call it straight from the browser with fetch. That is the main reason it beats the usual OpenWeatherMap tutorial for learning: there is no account to create and no secret to hide. For a real production app with heavy traffic you would still add a small cache or a backend proxy to respect the fair-use limits, but for learning and small projects, direct calls are fine.
Why does the app make two API calls?
Because the forecast endpoint speaks coordinates, not city names. The first call is geocoding: it turns "Lisbon" into a latitude and longitude. The second call is the forecast for those coordinates. This two-step shape is common across weather and mapping APIs, so it is worth learning once. In the code, getWeather runs geocodeCity first and then uses its result to build the forecast request, so from the component's point of view it is still one function call that returns one Weather object.
Open-Meteo vs OpenWeatherMap: which should I use?
For learning and small free projects, Open-Meteo is the easier pick because it needs no API key and no signup, so you can start in seconds. OpenWeatherMap is a strong, widely-used service with a large free tier, more data products, and a longer track record, but it requires an account and an API key you must keep out of your frontend bundle. If you are building something commercial or need a specific data feed OpenWeatherMap offers, weigh it then; if you just want a working weather app today, Open-Meteo removes the friction.
How do I handle a city that doesn't exist or a network error?
That is what the status state machine and the try/catch are for. The API module throws a real Error with a readable message when geocoding finds no match or a fetch fails. App wraps the call in try/catch, and on failure it sets status to error and stores the message, which the JSX renders as a red line instead of a broken page. Modeling status as a union of idle, loading, success, and error (rather than loose booleans) is what keeps the UI from ever showing a half-broken state.
Can I show the user's local weather instead of a search box?
Yes, and it is a small change. Call navigator.geolocation.getCurrentPosition, which gives you latitude and longitude directly, and skip the geocoding step. Split getWeather so the forecast half can take coordinates on their own, call it with the browser's position in a useEffect on first load, and you have local weather without anyone typing. Keep the search box too, so people can still look up other cities.
Is fetch enough, or do I need axios?
fetch is enough. It is built into every modern browser and Node 18+, handles JSON in two lines (await fetch, then await res.json()), and adds zero dependencies to your bundle. Libraries like axios add conveniences such as automatic JSON parsing and interceptors that help on larger apps, but for a project this size they are weight you don't need. This tutorial ships with zero runtime dependencies beyond React for exactly that reason.