Summit is a trail discovery platform for hikers who know what they want and need to find it quickly. Browse real OSM trail data, filter by difficulty and region, search by name or tag — and watch the map animate to the selected route in real time. Built from a static marketing site, converted into a fully componentized Next.js app with live data, state management, and animated path rendering.

Trail discovery is a decision-making problem. The interface is only useful if it helps someone commit to a route.
The core UX question for Summit was: what does a hiker need in order to choose a trail? Not just browse — actually decide. The answer is three things: difficulty, location, and a sense of the route's shape. Everything in the interface is built around surfacing those three signals as quickly as possible.
The layout keeps the map permanently visible while the trail card list scrolls independently beside it. This is not a layout preference — it's a UX requirement. If the map disappears when you scroll, you lose the spatial context that makes a trail card meaningful. "Emerald Ridge Traverse, 14.2km, Hard" is abstract information. The same card alongside a map showing that trail tracing the edge of a mountain ridge is a decision.
Filters reduce noise rather than reveal options. Difficulty and region filters are the first interaction most users need — they eliminate the trails that categorically don't fit before a single card is read. The search input handles specific intent: if you already know the name, the region, or a tag, you shouldn't have to scroll to find it. Both patterns exist because both mental models exist among real users.
Selecting a trail animates the map to draw the polyline path. The animation is not decorative — it spatially connects the card you tapped to the geography you're looking at. A static jump to coordinates would work technically but would feel arbitrary. The draw animation shows you where the trail goes, not just where it is.
Technical implementation follows ↓
Every choice was deliberate. Here's the rationale.
| Framework | Next.js 16 App Router with per-page CSS Modules. Converting from a static HTML file gave us code splitting, image optimization via next/image, and a clear component boundary structure. The static site had no place to put shared state — Next.js gave us that architecture. |
| Data | Overpass API + fetchTrails.ts A custom Node script queries OSM for hiking routes by region, normalizes raw geometry into typed Trail objects, calculates distance via Haversine formula, infers difficulty, and writes to a local trails.json. Overpass proved too unreliable for runtime fetching — the script runs on demand and the output is committed, giving us real OSM data without a live dependency. |
| Map Engine | Leaflet + react-leaflet Renders animated polyline paths from coordinate arrays with a stroke-dasharray draw animation on trail selection. The sticky sidebar layout keeps the map in view while the trail card list scrolls independently beside it. |
| State | Zustand A single store handles active trail, filter state (difficulty, region), and search query. The search input, active trail highlight on the map, and filter chips stay in sync without prop-drilling across the component tree. |
Search, difficulty, and region filtering all compose. The derived selector is the right place for that.
As the filter surface grew — search by name, region, tag; difficulty filter; region filter — the risk was scattering that logic across components that each implemented their own slice of it. A derived Zustand selector centralizes all three filters into a single pure function that any component can subscribe to. The TrailGrid doesn't know how filtering works; it just receives the filtered list.
The Leaflet map instance required careful management inside a useEffect with proper cleanup. Next.js in development mode mounts components twice via Strict Mode — without cleanup, the map would initialize twice on the same DOM node, producing a broken second instance. Storing the map ref and callingmap.remove() in the cleanup function prevents the double-mount entirely.
// Derived selector — all filter logic in one place
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.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;
});
}