I spend a lot of time on my bike. Cycling has become a core part of my routine, but as my mileage grew, I found myself wanting a better way to look at the data. Strava is great for the social feed, but I wanted something personal: a raw, telemetry-focused dashboard on my own domain that visualized my trips and tracked what I have accomplished this year.
I also wanted an excuse to experiment with MapLibre GL and build an interactive map interface.
This project was my first time working deeply with a third-party activity API ecosystem. Integrating with Strava forced me to navigate OAuth 2.0 grant flows, token rotation mechanics, and webhook event subscriptions without running a persistent database or stateful server.
Static Architecture and Build-Time Ingestion
My portfolio is a static Next.js App Router installation hosted on Vercel. I did not want to spin up a stateful node container just to serve cycling coordinates, nor did I want browser clients hitting the Strava API on every page load to avoid slow rendering times and aggressive API rate limiting.
Instead of fetching activities dynamically, a custom Node.js script runs during deployment right before next build. It authenticates with Strava, downloads historical ride logs, decodes GPS geometries, and compiles everything into a static cycling.json asset file. The Next.js components import this JSON directly during compilation, rendering map coordinate routes instantly without external runtime network latency.
To automate ongoing synchronization, I registered a Strava Push Subscription against a dedicated Next.js API route (/api/strava-webhook). Whenever a new Garmin or Wahoo bike computer synchronization finishes, Strava sends an event payload to the webhook endpoint. The route verifies the HMAC security signature and calls a Vercel Deploy Hook, triggering an automated rebuild that compiles the new activity into the static JSON payload within two minutes of finishing a ride.
OAuth Grant Recovery and Token Rotation
Handling unattended Strava synchronization requires rotating an OAuth refresh token over time. Because a static Next.js site lacks persistent disk storage between ephemeral serverless invocations, the build pipeline needed a lightweight cloud store to preserve authorization tokens across independent deployment cycles.
I utilized an Upstash Redis key-value instance for stateless secret persistence. When the pre-build pipeline executes, it retrieves the stored OAuth refresh token from Redis, requests a fresh short-lived access token from Strava's identity server, and writes the newly issued refresh rotation string straight back into Redis before executing telemetry requests.
Privacy Engineering via Polyline Clipping
Publishing raw cycling tracks to a public GitHub repository introduces serious real-world security exposure: every activity path directly reveals home, workplace, and friend departure addresses. Although Strava offers application-level privacy zones, relying on client-side visual masking leaves unaltered spatial coordinate histories inside repository Git commits and build assets.
To eradicate location exposure before files hit disk, the build pipeline runs a programmatic coordinate truncation step. As route polylines are ingested from API responses, the engine decodes encoded strings into coordinate latitude and longitude pairings, computing sequential waypoint increments using the Haversine distance equation. The truncation script steps along the sequence array from both directions and deletes exactly 1.0 mile of coordinate geography from the beginning and conclusion of every captured cycling route. Sensitive residential streets never reach repository JSON bundles.
// A simplified look at the clipping logic
export function clipRoute(polylineStr: string): [number, number][] {
const coords = polyline.decode(polylineStr)
let startIndex = 0
let endIndex = coords.length - 1
// Walk forward to find the 1-mile mark
let startDist = 0
for (let i = 1; i < coords.length; i++) {
startDist += haversineDistance(coords[i-1], coords[i])
if (startDist >= MILES_TO_CLIP) {
startIndex = i
break
}
}
// Walk backward to find the 1-mile mark from the end
let endDist = 0
for (let i = coords.length - 2; i >= 0; i--) {
endDist += haversineDistance(coords[i], coords[i+1])
if (endDist >= MILES_TO_CLIP) {
endIndex = i
break
}
}
return coords.slice(startIndex, endIndex + 1)
}
Content Curation via Title Metadata
I wanted an easy way to highlight extended excursions without maintaining an administrative CMS database. Instead of building manual review dashboards, the build engine parses activity title text from the raw Strava sync. Adding #feature to an activity title marks the ride for prominent rendering on the dashboard shelf, while tagging #hide excludes the record entirely during compile time. To keep visual headings clean, a string sanitization function scrubs trailing hash tags from final JSON title properties before display.
For spatial mapping, the UI integrates MapLibre GL rendered on top of the Carto Dark Matter basemap schema. To harmonize third-party mapping canvases with the dark portfolio theme, the application intercepts Carto style configuration arrays before rendering, substituting default ocean and territory fill hues to match our dark zinc layout backgrounds. Route lines display using dual-layered SVG path strokes to construct a subtle neon lighting visual effect.
Multi-Day Ride Grouping and Telemetry Math
Long-distance bike touring frequently requires splitting rides across consecutive tracking sessions, such as "Cycling to New Smyrna Part 1" and "Part 2". The ingestion engine evaluates activity timestamps and title similarities, automatically grouping split segment logs if they occur within a five-day temporal window.
Merging activities requires more than summing distances. The grouping function computes duration-weighted averages for dynamic metrics like heart rate BPM and cycling cadence RPM, concatenates the clipped interior GPS polyline sequences, and requests verified metabolic expenditure directly from Strava's detailed workout endpoints rather than summing estimated mechanical kilojoules. This prevents compound arithmetic inaccuracies across multi-day ride summaries.
WebGL Optimization and Stateless Deep Linking
Rendering dynamic MapLibre route path animations directly inside React DOM hierarchies introduced immediate computational overhead. Drawing 15,000 coordinate waypoints for a 70-mile cycling route at 60 frames per second across multiple concurrent canvas cards degraded mobile scrolling framerates and exhausted battery resources.
To restore interface responsiveness, I wrote a polyline decimation routine that downsamples dense GPS coordinate trails down to a maximum cap of 1,000 inflection points during initial build processing. On the client side, Framer Motion intersection observer bindings monitor view positioning (useInView), immediately halting active WebGL rendering loops whenever a map component scrolls out of the visible screen window. This reduced ambient GPU rendering overhead by more than 90 percent.
To support shareable direct links for individual rides without breaking WebGL context bindings, I integrated URL parameter triggers such as ?ride=cycling-to-new-smyrna-05-16-2026. However, driving modal open states through standard Next.js App Router navigation triggers cascading component tree re-renders that rapidly unmount and crash background MapLibre WebGL instances. Bypassing React Router wrappers entirely, the dashboard modal handler syncs internal component state directly against native browser history via window.history.pushState. This technique keeps deep links functional and shareable while isolating heavy background map canvases from unnecessary React DOM lifecycle churn.