EpicenterHub is a real-time seismic monitoring dashboard pulling live data from the USGS Earthquake API. The project started as a React + Vite app with three different charting libraries and no TypeScript. I migrated it to Next.js App Router — consolidating chart dependencies, typing the entire USGS GeoJSON surface, and layering in React Query, Zustand, and a portal-rendered detail drawer — without touching the live data pipeline.

A dashboard that shows everything at once shows nothing clearly. The design work is deciding what a user needs first.
The primary view of EpicenterHub shows a global map with magnitude-encoded markers, four summary statistics, and two charts — all without scrolling. That density is intentional. The target user is someone who opens the dashboard to get oriented quickly: where is seismic activity concentrated right now, how strong, how many events. Every element above the fold earns its place by answering one of those three questions.
The advanced analytics panel is deliberately gated behind a toggle. Depth distribution, temporal patterns, and the magnitude histogram are valuable but secondary — they answer follow-up questions, not the first one. Showing them by default would dilute the signal. The toggle gives power users access without burdening the primary view.
The detail drawer — triggered by clicking any earthquake on the map — renders in a portal at the root of the document. This was a deliberate UX decision: a drawer that renders inside the map component would be clipped by the map's overflow boundary and z-index context. By portalling it to the root, the drawer sits cleanly over the full viewport with no stacking context conflicts. The map stays interactive behind it.
Consolidating from three chart libraries to one (Recharts) was as much a design decision as an engineering one. Three libraries meant three slightly different font sizes, three different tooltip styles, three different animation behaviours. A dashboard reading as one coherent product requires one visual language.
Technical implementation follows ↓
Every choice was deliberate. Here's the rationale.
| Framework | Next.js 16 — App Router The original Vite app had no SSR concerns, but Next.js unlocked dynamic OG image generation, proper metadata per route, and the two-file pattern that makes Leaflet SSR-safe without hacks. |
| Data fetching | React Query + usgsApi.ts Replaces a hand-rolled useEarthquakeData hook (useState + useEffect + useInterval). React Query gives stale-while-revalidate, background refetch on window focus, deduplication, and a retry policy — for less code than the original. |
| State | Zustand — dashboardStore A single store owns filters, feed period, advanced panel visibility, active chart tab, and the selected earthquake. FilterControls reads and writes the store directly — zero prop-drilling, zero prop threading through page.tsx. |
| Map | Leaflet + react-leaflet Split into MapView.tsx (SSR-safe wrapper) and MapClient.tsx (browser-only). next/dynamic with ssr: false keeps the build from crashing on window. Tile brightness is reduced via CSS filter to fit the dark theme without a paid tile provider. |
| Charts | Recharts (consolidated) The original used ApexCharts, Chart.js, and D3 across four components. All four were rewritten in Recharts — the library already used in the basic chart panel — cutting three dependencies and unifying the visual language across every chart in the dashboard. |
The migration was straightforward. The types were the work.
The USGS GeoJSON feed returns a deeply nested structure — feature collections with coordinate tuples, optional felt reports, nullable magnitudes, alert levels, and a dozen fields that only appear on significant events. The original codebase accessed all of it with implicit any. The charts quietly rendered NaN. The map silently skipped malformed coordinates. No errors, no warnings — just wrong data.
A single types/earthquake.ts file now defines the full USGS GeoJSON surface — every property, every nullable field, the coordinate tuple typed as[number, number, number] rather than number[]. That one file propagates through every hook, service, component, and chart. The compiler catches a missing null check before it reaches the map renderer. The FeedPeriod union type is a good example — the USGS API supports eight endpoint variants and passing an invalid string was a silent 404. Now it's a compile error.
// types/earthquake.ts — the single source of truth
export type FeedPeriod =
| 'all_hour' | 'all_day'
| 'all_week' | 'all_month'
| 'significant_hour' | 'significant_day'
| 'significant_week' | 'significant_month';
export interface EarthquakeGeometry {
type: 'Point';
// [longitude, latitude, depth_km]
coordinates: [number, number, number];
}
export interface EarthquakeProperties {
mag: number | null;
place: string | null;
time: number;
felt: number | null;
alert: 'green' | 'yellow' | 'orange' | 'red' | null;
sig: number;
tsunami: 0 | 1;
// ... 15 more typed fields
}