Overview
Weather Now is intentionally a frontend application — no backend, no database. All the engineering lives in one question: how do you coordinate several external APIs, live data, and user preferences in the browser without the app ever feeling slow or jumpy?
Simple rule
Dependencies point inward: pages compose components, components read state, and services are the only code that talks to external APIs.
The Layers
| Layer | What it does |
|---|---|
| Server shell | app/page.tsx renders nav, heading, and search as static HTML — the first paint costs nothing. |
| Client islands | Only Search and ForecastContainer (and their children) hydrate. They own fetching and interactivity. |
| State | TanStack Query caches the weather payload by coordinates; Zustand holds location, unit preferences (persisted), and panel visibility. |
| Services | One module per external API — Open-Meteo forecast SDK, geocoding search, a BigDataCloud server action, browser geolocation. |
| Utils | Unit converters and WMO weather-code → icon/description mapping. |
Request flow
The weather fetch and the reverse-geocoding call run in parallel, and the result is cached by coordinate pair — revisiting a recent location never refetches.
State in Two Halves
Two kinds of state with different jobs:
- Server state (TanStack Query) — the weather payload. Cached, deduplicated, retried, keyed by
["weatherData", lat, lng]. - Client state (Zustand) — active location, unit preferences (persisted to
localStorage), units-panel visibility. Selector-based subscriptions mean a preference change re-renders only the widgets that read it.
The unit-switching trick
Data is stored raw in Celsius. Every widget converts at render time using shared converters. Switching units re-renders subscribers instantly — zero network requests.
Rendering Without Jank
The forecast grid is always mounted. While data is pending, each panel shows a shimmer skeleton shaped exactly like the final widget; when data arrives it fills in place.
- No loading-box swap → no layout shift.
- No single giant render when the payload resolves → each section fills independently.
- Hourly rows group once per payload (
useMemo), not on every render. - Skeleton shimmer respects
prefers-reduced-motion.
Accessibility as Architecture
- Search is a full ARIA 1.2 combobox:
role="combobox",aria-expanded,aria-activedescendant, listbox/option semantics, arrow keys + Home + End + Enter + Escape. - Units panel: focus moves in on open, Escape returns focus to the trigger; options are real buttons with
aria-pressed. - Skip link, consistent
focus-visibleoutlines, 44 px minimum targets, meaningful icon alt text (weather descriptions double as alt).
Hard Problems Solved
| Problem | Root cause | Fix |
|---|---|---|
| Stale search results flickered for fast typists | Older responses resolving after newer ones | 500 ms debounce + per-request AbortController; superseded requests cancelled |
| Layout shift + one blocking render (~40 widgets, ~30 images) when data landed | Loading box swapped for the whole grid | Always-mounted skeleton containers sized to final layout; parallel fetches; memoized hourly grouping |
| Open-Meteo variables unreadable by name | SDK exposes values positionally; daily min/max encode as the same type | Documented index contract in the service layer; WMO codes mapped centrally |
| Unit switching felt slow or refetched | Per-unit fetches | Raw Celsius storage + render-time conversion from persisted preferences |
| First visit stalled on the geolocation prompt | Satellite fix can take seconds; some devices have no GPS | Coordinates cached in localStorage, used instantly on revisit; fresh fix overwrites cache silently |
Result
A single-page app that paints instantly, never jumps, never lies about search results, and works for keyboard, screen reader, and touch users alike.