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 more personal—a raw, telemetry-focused dashboard on my own domain that visualized my trips and built a cohesive narrative around what I've accomplished this year.
I also wanted an excuse to play around and build a fun UI.
This project was my first time working deeply with a public company's API ecosystem. Integrating with a mature platform like Strava forced me to navigate OAuth 2.0 flows, secure token rotation, and webhook subscriptions. Here is a look at the technical decisions that went into building the dashboard.
The Architecture: Static and Automated
My portfolio is a static Next.js App Router site hosted on Vercel. I didn't want to spin up a traditional backend just to serve cycling data, nor did I want to hit the Strava API on every page load (which is slow and risks hitting rate limits).
The solution was a build-time data pipeline.
Instead of fetching data on the fly, I wrote a Node.js script that runs right before next build. It reaches out to Strava, downloads my activities, processes the geometry, and compiles everything into a static cycling.json file. The Next.js frontend then simply imports that JSON file at build time. The result is a dashboard that loads instantly.
To keep the site updated without manual intervention, I registered a Strava Push Subscription. Whenever I finish a ride and upload it, Strava fires a webhook to my Next.js API route (/api/strava-webhook). That route verifies the payload and triggers a Vercel Deploy Hook, kicking off a fresh build that pulls in the new ride.
OAuth and Token Rotation
Handling authentication with Strava requires rotating an OAuth refresh token. Since my site has no traditional database, I needed a secure place to store the token between ephemeral Vercel builds.
I opted for Upstash Redis. The build script reads the current refresh token from Redis, asks Strava for a fresh access token, and then immediately writes the new refresh token back to the database. It's a lightweight, serverless approach to state management that keeps the pipeline fully automated.
Engineering for Privacy
One of the biggest risks of publishing raw GPS data is exposing your exact home and work addresses. Strava has privacy zones, but I wanted absolute control over what was committed to my public repository.
To solve this, I wrote a custom programmatic clipping script. As the build pipeline ingests route polylines, it decodes them into coordinates and uses the Haversine formula to calculate distance. The script walks the array of coordinates and automatically deletes the first and last 1.0 mile of every single ride. By the time the data is written to the static JSON file, the sensitive coordinates are already gone.
// 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)
}
Curating the Dashboard
I wanted to display my longest and most interesting "Featured Rides," but I didn't want to build a custom CMS to manage them. Instead, I use Strava's activity title as my command line. If I append #feature to a ride's name, the build script flags it for the Featured Shelf. I can also use #hide to keep a ride off the site entirely. To keep the UI pristine, a custom cleanName function strips these hashtags out of the final JSON payload before it ever reaches the browser.
For the visuals, I integrated MapLibre GL using the Carto Dark Matter base map. To make it fit the site's aesthetic, I dynamically intercept the Carto style JSON before rendering to swap the land and water colors, matching the deep zinc backgrounds of the UI. The routes are drawn using a two-layer SVG stroke to create a neon glowing effect.
Building this dashboard was a great exercise in system design. It required piecing together disparate systems—OAuth, Redis, Webhooks, Next.js, and MapLibre—into a single, automated pipeline that requires zero maintenance. The best infrastructure is the kind you never have to think about.
Handling Multi-Day Rides and Telemetry
Sometimes I break long rides into multiple segments (like "Cycling to New Smyrna (Part 1)" and "(Part 2)"). I updated the build script to automatically group these multi-part activities if they occur within 5 days of each other. The pipeline computes weighted averages for telemetry like heart rate and cadence, concatenates the clipped GPS coordinates, and fetches the true human calorie expenditure directly from Strava's detailed activity endpoint (rather than relying on raw mechanical kilojoules) to ensure the aggregated metrics are perfectly accurate.
WebGL Animation Performance and Deep Linking
To make the dashboard feel alive, the MapLibre routes draw themselves dynamically. But animating 15,000+ coordinates for a 70-mile ride at 60fps across multiple maps destroyed browser performance and battery life. I implemented a polyline decimation algorithm to downsample the routes to a maximum of 1,000 points during the build process, and used Framer Motion's useInView to completely pause the WebGL animation loops when maps scroll out of the viewport. This simple change reduced the drawing overhead by over 90%.
I also added an interactive modal to view extended stats for featured rides. To allow these specific rides to be deep-linked and shared, I integrated URL query parameters (e.g., ?ride=cycling-to-new-smyrna-05-16-2026). However, because Next.js's App Router can trigger cascading re-renders on route changes, the MapLibre components in the background were unmounting and crashing. I bypassed the Next.js router entirely by syncing local React state directly with native window.history.pushState. This allows for buttery smooth modal transitions and shareable URLs while maintaining rock-solid WebGL canvases in the background.