マラソン2時間16分のエンジニアが、毎朝の狂気的なランニング記録をNext.js×Strava APIで可視化した話【バイブコーディング実践】
はじめに
ほぼ毎朝03:45に起きて12kmを走る。まだ最近始めたばかりの習慣だ。
走るのは習慣だが、記録を見せる仕組みは自分で作らなければならない。Stravaにはデータが蓄積されているため、それをリアルタイムでWebサイトに表示しようと考え、自身のポートフォリオサイトにStrava連携ダッシュボードを組み込んだ。この記事では、その技術的な構成を共有する。
技術スタック
| 項目 | 選定 |
|---|---|
| フレームワーク | Next.js 16 (App Router) |
| 言語 | TypeScript |
| スタイリング | Tailwind CSS v4 |
| アニメーション | Framer Motion |
| デプロイ | Vercel |
| データ | Strava API v3 |
Strava API の認証フロー
StravaのOAuth2認証は、個人利用であればRefresh Tokenを使った方法が最もシンプルに実装できる。
アプリケーション登録
Strava API Settingsからアプリを作成し、Client IDとClient Secretを取得する。
Authorization Code の取得
ブラウザで以下のURLにアクセスし、リダイレクト先のURLパラメータからcodeを取得する。
https://www.strava.com/oauth/authorize?client_id={CLIENT_ID}&response_type=code&redirect_uri=http://localhost&scope=read,activity:read_all
Refresh Token の取得
以下のコマンドを実行し、返却された refresh_token を .env.local に保存する。
curl -X POST https://www.strava.com/oauth/token \
-d client_id={CLIENT_ID} \
-d client_secret={CLIENT_SECRET} \
-d code={CODE} \
-d grant_type=authorization_code
Server Component でのデータフェッチ
Next.js App RouterのServer Componentを活用し、ビルド時ではなくリクエスト時にデータを取得する。
// lib/strava.ts
import "server-only";
import { unstable_cache } from "next/cache";
const REVALIDATE_SECONDS = 300; // 5分キャッシュ
async function refreshAccessToken(): Promise<string | null> {
const res = await fetch("https://www.strava.com/oauth/token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
client_id: process.env.STRAVA_CLIENT_ID,
client_secret: process.env.STRAVA_CLIENT_SECRET,
grant_type: "refresh_token",
refresh_token: process.env.STRAVA_REFRESH_TOKEN,
}),
cache: "no-store",
});
if (!res.ok) return null;
const data = await res.json();
return data.access_token;
}
const getCachedAccessToken = unstable_cache(refreshAccessToken, ["strava-access-token"], {
revalidate: REVALIDATE_SECONDS,
});
server-onlyパッケージを用いてクライアントサイドでの誤インポートを防ぎつつ、unstable_cacheでトークンを5分間キャッシュしてAPIのコール数を削減している。
今日の走行データの集約
Strava APIは1回の走行を1アクティビティとして返すが、朝ジョグとインターバルなど、1日に複数のアクティビティを記録することもある。そのため、当日のランニングアクティビティをすべて合算して処理する設計とした。
export async function getTodaysRun(): Promise<TodaysRun> {
const token = await getCachedAccessToken();
if (!token) return EMPTY_RUN;
const after = getTodayStartUnix("Asia/Tokyo");
const url = `${ACTIVITIES_URL}?after=${after}&per_page=30`;
const res = await fetch(url, {
headers: { Authorization: `Bearer ${token}` },
next: { revalidate: 300 },
});
const activities = await res.json();
const runs = activities.filter(isRun);
// 距離・タイム・標高を合算
const totalDistanceM = runs.reduce((sum, a) => sum + a.distance, 0);
const totalMovingSec = runs.reduce((sum, a) => sum + a.moving_time, 0);
const totalElevM = runs.reduce((sum, a) => sum + a.total_elevation_gain, 0);
return {
hasRun: true,
distanceKm: totalDistanceM / 1000,
movingTimeSec: totalMovingSec,
paceSecPerKm: totalMovingSec / (totalDistanceM / 1000),
elevationGainM: totalElevM,
// ...
};
}
Polyline マップの描画
Strava APIは走行ルートをEncoded Polyline形式で返すため、これをデコードして座標データに変換する。
function decodePolyline(encoded: string): [number, number][] {
const points: [number, number][] = [];
let index = 0,
lat = 0,
lng = 0;
while (index < encoded.length) {
let shift = 0,
result = 0,
byte: number;
do {
byte = encoded.charCodeAt(index++) - 63;
result |= (byte & 0x1f) << shift;
shift += 5;
} while (byte >= 0x20);
lat += result & 1 ? ~(result >> 1) : result >> 1;
shift = 0;
result = 0;
do {
byte = encoded.charCodeAt(index++) - 63;
result |= (byte & 0x1f) << shift;
shift += 5;
} while (byte >= 0x20);
lng += result & 1 ? ~(result >> 1) : result >> 1;
points.push([lat / 1e5, lng / 1e5]);
}
return points;
}
デコードした座標はSVGの<polyline>タグで直接描画する。外部の地図タイルを使用せず、ルートのシルエットのみを描画することでUIを軽量化している。
UIデザインとパフォーマンス最適化
サイト全体は純黒(#000000)を背景とし、シグナルカラーとしてネオンイエローグリーン(#e2ff3a)を配置している。フォントにはGeist SansとGeist Monoを採用し、Framer Motionによるスクロール連動アニメーションを実装した。
走行データパネルは、走った日はシグナルカラーのグローで発光し、走っていない日はグレーアウトされるため、当日の走行状況がひと目で把握できる。
パフォーマンス面では、unstable_cacheとnext.revalidateを用いた5分間隔のISRによってAPIリクエストを最小化している。データフェッチをサーバーコンポーネントで完結させ、地図ライブラリの代わりにSVGを用いることで、クライアントに送信するバンドルサイズを抑制した。
まとめ
Strava APIとNext.jsを組み合わせることで、ほぼリアルタイムの走行ダッシュボードが完成した。Refresh Tokenによる認証の永続化、サーバーコンポーネントとISRによる効率的なデータフェッチ、そしてSVGでの軽量なマップ描画を統合することで、日々のランニング実績を視覚的に提示する仕組みとして機能している。