A React app that lets you look up live weather and flight info for any major airport. Weather comes from Weatherstack, flights and airport search come from Aviationstack. You can favorite airports so they show up in a dropdown at the top of the page.
- Search airports by city name or IATA code. The dropdown autocompletes as you type by calling the Aviationstack airports endpoint (debounced so it doesn't fire on every keystroke).
- Location page at
/location/:iatafires the weather, arrivals, and departures requests at the same time. - Weather details: temperature, feels-like, wind speed / direction / bearing, humidity, pressure, cloud cover, precipitation, UV index, visibility, day or night.
- °C / °F toggle inside the weather card. Your pick is saved to
localStorage. - Two short flight boards per location: 3 departures and 3 arrivals. Each row shows the flight number, airline, partner airport, scheduled time, and status.
- Click "Details" on a flight for a modal with the full info: aircraft, terminals, codeshares, scheduled vs actual times.
- Favorites: one click to save an airport. Saved airports show up in a dropdown in the navbar and persist in
localStorage. - Recent searches show up inside the search dropdown when the input is focused.
- Home page has 6 suggested airports (JFK, LAX, LHR, HND, DXB, SYD) so you have somewhere to click if you don't know what to search.
- Light and dark mode button in the top-left of the navbar. Saved to
localStorage. - Errors (unknown airport, rate limit, paid-only endpoint) show a message with a retry button instead of crashing.
- Two routes using React Router:
/and/location/:iata.
| Tool | What for |
|---|---|
| React 18 | UI |
| Vite | Dev server, bundler, and the API proxy |
| React Router v6 | Routing between Home and the location page |
| Weatherstack | Weather data |
| Aviationstack | Flights and airport search |
| CSS Modules | Component-scoped styles |
localStorage |
Storing favorites, recents, theme, and °C/°F preference |
All four values use the same useLocalStorage hook (src/hooks/useLocalStorage.js), which JSON.stringifys on write and JSON.parses on read. Keys are namespaced with weather-unlimited: so they don't collide with anything else on the same origin.
| Key | Set by | Shape | Default |
|---|---|---|---|
weather-unlimited:favorites |
FavoritesContext |
Array of airport objects ({ iata, city, country, ... }) |
[] |
weather-unlimited:recents |
RecentsContext |
Array of airport objects, most recent first | [] |
weather-unlimited:theme |
ThemeContext |
"light" or "dark" |
"light" |
weather-unlimited:units |
UnitsContext |
"C" or "F" |
"F" |
The browser never calls the external APIs directly. It calls a proxy in vite.config.js that adds the access key on the server side.
| My endpoint | Proxies to | What it does |
|---|---|---|
/api/weather?query=London |
Weatherstack /current |
Current weather for a city |
/api/flights?dep_iata=JFK |
Aviationstack /v1/flights |
Departures from an airport |
/api/flights?arr_iata=JFK |
Aviationstack /v1/flights |
Arrivals into an airport |
/api/airports?search=London |
Aviationstack /v1/airports |
Airport search for the autocomplete dropdown |
The weather endpoint takes a query param which can be a city name, a City,Country string, or a lat,lon pair. The flights endpoint uses either dep_iata (for departures) or arr_iata (for arrivals) — never both. The airports endpoint takes a partial string and returns up to limit matches.
The app does all network work asynchronously so the UI never blocks on a request.
- Parallel requests on the location page.
LocationDetailneeds three things — current weather, arrivals, and departures — and they're independent, so it fires them at the same time withPromise.allSettled([...]).allSettled(instead ofPromise.all) means one failure doesn't take down the others: if Aviationstack is rate-limited, the weather card still renders and only the flight boards show an inline error. - Partial-result rendering. State is stored as a single object with separate
weatherError/flightsErrorfields, so each block decides on its own whether to render data or an error with a "Try again" button. useFetchhook. A small custom hook (src/hooks/useFetch.js) wraps the standarddata / error / loadingpattern. It tracks mount state with a ref and acancelledflag in the effect cleanup, so if the component unmounts (or the dependency changes) before the promise resolves, the result is ignored — no setState-after-unmount warnings and no stale data flashing on screen.- Debounced autocomplete.
SearchBardoesn't fire a request on every keystroke. It waits until the input has been idle for a short window, then calls/api/airports?search=.... Old in-flight searches are cancelled when a newer keystroke arrives, so the dropdown only ever shows results for the latest query. - Retry on demand. The "Try again" button on an
ErrorMessagebumps anattemptcounter that's part of the effect's deps, which re-runs the wholePromise.allSettledblock.
npm installnpm run dev
Followed by opening http://localhost:5173/
weather-unlimited/
├── docs/
├── functions/
│ └── api/
│ ├── weather.js Cloudflare Pages Function: /api/weather
│ ├── flights.js Cloudflare Pages Function: /api/flights
│ └── airports.js Cloudflare Pages Function: /api/airports
├── public/
│ └── header.png Home hero image
├── src/
│ ├── api/
│ │ ├── weather.js Weatherstack wrapper
│ │ ├── flights.js Aviationstack flights wrapper (arrivals and departures)
│ │ └── airports.js Aviationstack airport search wrapper
│ ├── components/
│ │ ├── NavBar.jsx/.module.css Top nav + favorites dropdown
│ │ ├── ThemeToggle.jsx/.module.css Light / dark mode button
│ │ ├── UnitToggle.jsx/.module.css °C / °F segmented control
│ │ ├── SearchBar.jsx/.module.css Debounced airport autocomplete
│ │ ├── WeatherCard.jsx/.module.css Gradient hero + details pills
│ │ ├── WeatherIcon.jsx/.module.css Inline SVG icons keyed to weather description
│ │ ├── FlightList.jsx/.module.css Arrivals or departures board
│ │ ├── FlightRow.jsx/.module.css
│ │ ├── FlightModal.jsx/.module.css Popup with the full flight info
│ │ ├── FavoriteButton.jsx/.module.css
│ │ ├── LocationCard.jsx/.module.css
│ │ ├── HomeBackdrop.jsx/.module.css Decorative image for the Home hero
│ │ ├── Loader.jsx/.module.css
│ │ └── ErrorMessage.jsx/.module.css
│ ├── context/
│ │ ├── FavoritesContext.jsx
│ │ ├── RecentsContext.jsx
│ │ ├── ThemeContext.jsx Light / dark mode
│ │ └── UnitsContext.jsx °C / °F preference
│ ├── data/
│ │ └── airports.js The 6-airport seed list for the Home page
│ ├── hooks/
│ │ ├── useLocalStorage.js
│ │ └── useFetch.js Async fetch wrapper with cancel-on-unmount
│ ├── pages/
│ │ ├── Home.jsx/.module.css
│ │ └── LocationDetail.jsx/.module.css Fires weather + arrivals + departures in parallel
│ ├── utils/
│ │ ├── units.js Temperature conversion + formatting
│ │ └── flightStatus.js Maps a flight status string to the pill color class
│ ├── App.jsx NavBar + route definitions
│ ├── index.css Small reset + base typography
│ └── main.jsx React entry, wires providers + router
├── .gitignore
├── index.html
├── package-lock.json
├── package.json
├── README.md
└── vite.config.js Proxy that adds API keys on the server side
The app talks to both APIs through a small proxy in vite.config.js that adds the access keys on the server side. That's why the browser's Network tab only ever shows /api/weather?..., /api/flights?..., and /api/airports?... — no key in the URL.
You can hit those same proxy endpoints from the terminal. No keys needed as long as the dev server is running.
# Current weather for a city
curl -s "http://localhost:5173/api/weather?query=London" | jq
# By IATA code
curl -s "http://localhost:5173/api/weather?query=JFK" | jq
# "City,Country" format (what LocationDetail actually uses)
curl -s "http://localhost:5173/api/weather?query=Tokyo%2CJapan" | jq
# Just the 'current' block (what the WeatherCard renders)
curl -s "http://localhost:5173/api/weather?query=Paris" | jq '.current'
# Trigger the "no matching location" error path
curl -s "http://localhost:5173/api/weather?query=zzzzzzzzz" | jq '.error'# Departures from JFK (the "Departing from" board)
curl -s "http://localhost:5173/api/flights?dep_iata=JFK&limit=20" | jq
# Arrivals into JFK (the "Arriving in" board)
curl -s "http://localhost:5173/api/flights?arr_iata=JFK&limit=20" | jq# What SearchBar fires on each debounced keystroke
curl -s "http://localhost:5173/api/airports?search=London&limit=6" | jq
# Empty-result path (no airport matches)
curl -s "http://localhost:5173/api/airports?search=zzzzzzzzz&limit=6" | jq '.data | length'The app is deployed to Cloudflare Pages. Two pieces of infrastructure work together:
- Static frontend —
npm run buildproducesdist/, which Cloudflare's CDN serves globally. - Pages Functions — every file under
functions/api/*.jsbecomes a serverless endpoint at the matching/api/*URL. They run on Cloudflare Workers (V8 isolates, not Node) and use standardRequest/Responseinstead of Node'sreq/res.
| Environment | What handles /api/* |
|---|---|
Local dev (npm run dev) |
Vite's server.proxy block in vite.config.js |
| Cloudflare production | The handlers in functions/api/*.js |
Both attach the access key on the server side and forward to Weatherstack / Aviationstack. The frontend code is identical in both — it just calls /api/... and doesn't know or care which proxy is on the other side.
Each Pages Function sets Cache-Control: s-maxage=... so Cloudflare's edge caches responses globally. Multiple users hitting the same query within the cache window share one upstream call:
| Endpoint | Edge cache | Why |
|---|---|---|
/api/weather |
5 min | Weather doesn't change second-to-second |
/api/flights |
2 min | Flight boards move, but slowly |
/api/airports |
1 hour | Airport list is essentially static |
This is the main defense against burning through Aviationstack's 100-requests-per-month free tier.
- Framework preset: Vite
- Build command:
npm run build - Build output directory:
dist - Root directory: (blank)
Set in Settings → Environment variables, scoped to Production and Preview:
WEATHERSTACK_KEY=...
AVIATIONSTACK_KEY=...