EpicenterHub is a real-time seismic monitoring dashboard pulling live data from the USGS Earthquake API. The project began as a React + Vite app with three different charting libraries and no TypeScript. I migrated it to Next.js App Router — consolidating the 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.

Every choice was deliberate. Here's the rationale.
| Framework | Next.js 16 — App Router The original Vite app had no SSR concerns, but moving to 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. |
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, which meant runtime errors were invisible until they hit the browser. The charts quietly rendered NaN. The map silently skipped malformed coordinates.
A single types/earthquake.ts file 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 in the app. The compiler now catches a missing null check before it reaches the map renderer.
The FeedPeriod union type is a good example of TypeScript earning its keep — 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
}Hindsight is the best code reviewer.
The heatmap is the weakest chart in the advanced panel. Recharts has no native heatmap type, so I built one from a ScatterChart with custom square dot shapes — it works, but the cell sizing is fixed rather than responsive to the container width. A proper solution would calculate cell dimensions from the ResizeObserver entry and redraw on container resize, which is the approach D3 would have made natural. The consolidation to one library was the right call for bundle size; the heatmap is where it cost something.
I'd also add URL-synced filter state from the start using nuqs. Right now, refreshing the page resets all filters to default — which is fine for a dashboard, but loses the ability to share a specific filtered view via URL. It's the next feature on the list, and it would have been cheaper to design for from the beginning than to retrofit into the Zustand store after the fact.