A no-nonsense weather dashboard built with Next.js 16, React 19, and TypeScript. Hits the free Open-Meteo APIs for forecast data and geocoding. Runs as a full PWA: installable, offline-first, and production-ready with typed data flows, clear component boundaries, and a UI that handles light/dark without fighting Tailwind.
Live: weather.stackbp.es
pnpm install # deps
pnpm dev # local at localhost:3000
pnpm build # production build (--webpack for Serwist)
pnpm lint # catch issues
pnpm typecheck # tsc --noEmit (lint does NOT typecheck)
pnpm test:run # vitest, CI mode
pnpm format # prettierNo configuration is required: every weather API used here is free and keyless.
All optional — the app runs fine without them. Copy .env.example to
.env.local if you want any.
| Variable | What it does |
|---|---|
NEXT_PUBLIC_GA_ID |
Google Analytics measurement ID (G-XXXXXXXXXX). Unset ⇒ the GA scripts are skipped |
NEXT_PUBLIC_GA_ID is not a secret — it ships to the browser in the HTML. It
lives in an env var so a fork or a local build does not report into someone
else's property.
Deploying:
NEXT_PUBLIC_*variables are inlined at build time, so this one must be set in the hosting provider before building. If it is missing, analytics stops silently — the app itself is unaffected.
- Real weather data from Open-Meteo with 5-minute revalidation, null handling, and type-safe responses via Zod.
- Location model: on first visit (no city saved) it geolocates via browser geolocation + reverse geocoding (Nominatim). A manually chosen city is persisted to localStorage and wins on reload; GPS results are never persisted (recomputed each visit). The geolocation button re-detects your current location on demand.
- City search via Open-Meteo's geocoding endpoint with debounce and continent filtering.
- Client data fetching via SWR with no focus revalidation waste; reconnect + hourly refresh enabled.
- Unit toggles: Celsius/Fahrenheit, 7-day/16-day forecasts.
- Weather alerts for rain and heat thresholds.
- Hourly carousel showing 24h forecast with temps and precipitation.
- Clickable hourly/daily forecasts - detailed modals with wind gusts, cloud cover, humidity, precipitation probability, sunshine hours, daylight duration, UV index, and more.
- WMO code mapping for every weather condition (drizzle through thunderstorms) with color coding.
- Theme toggle that respects system preferences with semantic Tailwind tokens.
- Installable on iOS, Android, and desktop as a native app.
Serwist powers this with Workbox under the hood:
- Offline-first: Works without network after first visit.
- Install prompts: Floating button on Android/desktop; iOS gets a step-by-step modal for "Add to Home Screen."
- Auto-updates: SW checks hourly for new versions. Toast notification appears bottom-right with "Actualizar" button for smooth reload.
- Cache strategies:
- Weather API: StaleWhileRevalidate (30min TTL) — fast cached responses, background revalidation.
- HTML: NetworkFirst — try fresh, fall back to cache if offline.
- Images/Icons: CacheFirst (30 days, 100 entries max).
- CSS/JS: CacheFirst (1 year, hashed assets).
- Google Fonts: CacheFirst (1 year).
Service worker lives in app/sw.ts, bundled at build time to public/sw.js. Disabled in dev mode to avoid cache headaches.
Three client components handle the PWA lifecycle:
ServiceWorkerRegister: Registers SW, polls for updates.UpdateNotification: Toast when new version is ready.InstallPrompt: Platform-aware install button + iOS instructions.
All imported in app/layout.tsx alongside Analytics/SpeedInsights.
app/
├── page.tsx # Re-exports the weather page as the root route
└── weather/
├── page.tsx # Thin entry: renders <WeatherClient/>
└── services/
├── weather-service.ts # Zod schema, typed fetcher, URL builder
├── city-utils.ts # Geolocation (Nominatim), popular cities, continent mapping
└── weather-utils.tsx # WMO code → icon/text mapping (28+ conditions)
hooks/
├── useWeatherLocation.ts # Selected city: stored-manual vs. geolocation, persistence
├── useCitySearch.ts # Debounced geocoding search + continent filter
└── useTempUnit.ts # Celsius/Fahrenheit preference, value conversion + formatting
components/weather/
├── WeatherClient.tsx # State orchestrator: city, search, units, SWR
└── sections/
├── HeaderBar.tsx # Theme toggle, live badge
├── CitySelector.tsx # Current city, geolocation button
├── CityModal.tsx # City picker with search + continent filter
├── CurrentWeather.tsx # Hero: temp toggle, condition, icon
├── WeatherDetails.tsx # Humidity, wind, precip, pressure, UV, sunrise/sunset, clouds
├── HourlyForecast.tsx # 24h carousel with temps + precip %
├── HourlyDetailModal.tsx # Clickable hourly detail modal
├── WeatherAlerts.tsx # Rain/heat warning banners
├── Forecast.tsx # 7/16 day cards with detailed WMO descriptions
├── DailyDetailModal.tsx # Clickable daily detail modal
└── FooterInfo.tsx # Attribution, tech stack
app/components/ # PWA glue
├── InstallPrompt.tsx
├── ServiceWorkerRegister.tsx
└── UpdateNotification.tsx
Components stay small, focused, and testable. Services handle the messy stuff (fetching, validation, geolocation).
- Entry:
app/weather/page.tsxjust renders the client componentWeatherClient.tsx. There is no server-side weather fetch. - Location resolution (
useWeatherLocation): on mount, if a manually-chosen city is in localStorage it is used as-is. Otherwise it callsnavigator.geolocation.getCurrentPosition()(10s timeout, 5min cache) and reverse-geocodes via Nominatim. GPS results are applied to state but not persisted. While resolving, an initial skeleton is shown. - Weather fetch: once a city is set,
WeatherClientrunsuseSWR(["weather", cityId, days]). The fetcher callsfetchWeather()which:- Builds URL with lat/lon, current/hourly/daily blocks
- Validates response against Zod schema
- Normalizes nulls/numbers
- Returns typed
WeatherData
- Revalidation: focus revalidation is disabled; reconnect revalidation uses SWR defaults. Failed requests retry up to twice (5s apart), except on timeout/abort. Add a
refreshIntervalif you want periodic polling.
Everything stays free-tier friendly—no API keys, no paid limits.
- Next.js 16 (App Router, React 19)
- TypeScript (strict mode)
- Tailwind CSS 3.4.18 with semantic tokens
- SWR 2.3.7 for data fetching
- Zod 3.25.76 for runtime validation
- Serwist 9.5.0 (service worker)
- Lucide React 0.469.0 for icons
- Vercel Analytics + Speed Insights
- Theming:
darkMode: "class"with CSS variables inapp/globals.css. Use semantic classes likebg-layer-1,text-text-secondary—no hardcoded hex. - Imports order: React/Next → third-party →
@/aliases → relative. No unused imports. - Secrets: Keep them server-side. No
.envneeded for Open-Meteo (it's free). The only variable isNEXT_PUBLIC_GA_ID— see Environment variables. - Accessibility: Semantic HTML,
aria-labelon icon buttons, focus-visible states. - Build quirk:
pnpm build --webpackbecause Serwist doesn't support Turbopack yet. Handled inpackage.jsonscripts.
- Change default city: Edit
app/weather/page.tsx, pick another fromgetPopularCities(). - Add metrics: Expand the Zod schema in
weather-service.ts, reuseWeatherDetailstiles. - Tweak caching: Edit
app/sw.ts—changemaxAgeSeconds,maxEntries, or swap strategies. - Adjust geolocation: Edit
city-utils.ts—timeout, accuracy, cache duration. - More WMO codes: Extend
weather-utils.tsxwith new mappings. - Route-level revalidate: Add
revalidateconst if traffic increases. - Charts: Lazy-load, keep initial bundle lean.
Deploy to Vercel (HTTPS required for Service Workers):
- Install: Visit on mobile/desktop, tap "Instalar App."
- Offline: Install, disconnect network, open app—still works from cache.
- Updates: Deploy new version, open installed app. Within an hour, toast appears. Click "Actualizar" to reload.
- Geolocation: First load (or location button click) triggers browser prompt. Grant to auto-detect city.
Service Workers only run in production (HTTPS or localhost). Disabled in dev mode.
v1: Single page.tsx, useEffect + fetch (learning API calls)
v2: Split components, SWR, Zod validation (production data layer)
v3: PWA + service worker + manifest (installable, offline)
v4: Geolocation + reverse geocoding + curated (smart defaults)
v5: Multiple cache strategies + auto-updates (bulletproof)
v6: Current - production PWA + monitoring (shipping)
Started as a blog example for understanding API calls. Became a production app because why not.
Built to be read, tweaked, and enjoyed.