Summit is a trail discovery platform built for hikers who expect speed. The project started as a static marketing site; I converted the entire thing into a componentized Next.js app — with a live Leaflet map, animated path rendering, real OSM trail data via Overpass API, and a Zustand store managing filters, search, and trail selection state.

Every choice was deliberate. Here's the rationale.
| Framework | Next.js 16 App Router with per-page CSS Modules. The static site had no component boundaries — porting it gave us code splitting, image optimization, and a clear place to put state. |
| Data | Overpass API + fetchTrails.ts A custom Node script queries OSM for hiking routes by region, normalizes the raw geometry into typed Trail objects, calculates distance from Haversine, infers difficulty, and writes to a local trails.json. |
| Map Engine | Leaflet + OSM Renders animated polyline paths from coordinate arrays. Sticky sidebar layout keeps the map in view while the trail card list scrolls independently. |
| State | Zustand A single store handles active trail, filter state, and search query. Lightweight enough to not need a provider, but structured enough to avoid prop-drilling across the trail grid. |
The conversion was the challenge — not just the map.
The original site was a monolithic HTML file with inline styles and no component structure. Moving to Next.js meant decomposing it into ~10 components (Navigation, Hero, TrailGrid, TrailMap, Pricing, etc.) while preserving pixel-accurate layout — and then layering in dynamic behaviour that didn't exist before: trail filtering, search, map path animation, scroll-reveal, and sticky sidebar interaction.
Each section became an isolated component with its own CSS Module. State lived in a Zustand store rather than component-local state, so the search input, active trail highlight, and filter chips all stayed in sync without prop-drilling. The Leaflet map instance was managed inside a useEffect with proper cleanup to avoid the double-mount issue Next.js causes in dev mode.
The Worker also handles bounding-box computation and elevation gain calculations — keeping the main thread free for interaction response.
// Derived selector — keeps filtering logic out of components
export function useFilteredTrails(): Trail[] {
const { trails, searchTerm, difficultyFilter, regionFilter } = useTrailStore();
return trails.filter((trail) => {
const term = searchTerm.toLowerCase();
const matchesSearch =
!term ||
trail.name.toLowerCase().includes(term) ||
trail.region.toLowerCase().includes(term) ||
trail.country.toLowerCase().includes(term) ||
trail.tags.some((t) => t.toLowerCase().includes(term));
const matchesDifficulty =
difficultyFilter === 'All' || trail.difficulty === difficultyFilter;
const matchesRegion =
regionFilter === 'All' || trail.region === regionFilter;
return matchesSearch && matchesDifficulty && matchesRegion;
});
}Hindsight is the best code reviewer.
The Overpass API proved unreliable enough during development that the final trails.json is a normalized, hand-curated dataset rather than live-fetched data — the script exists and works, but Overpass rate-limits and 406 errors made it unsuitable as a runtime dependency. For a production system I'd run the fetch on a schedule and cache the output, rather than querying at build time.
I'd also type the Leaflet map instance more strictly from the start. The useEffect cleanup and the ref pattern for storing the map object started loose and needed tightening after the fact — a custom useLeafletMap hook that returned a typed ref would have kept that logic contained and reusable across the map components.