箱根駅伝2度出場 × マラソン2時間16分台のエンジニアが、毎朝のランニング記録を Next.js 16 × React 19 × Strava API で可視化した話【バイブコーディング実践】
⚠ NOTICE — この記事の文体について
本記事の文章は 生成 AI(Claude)の協力のもとで執筆しています。実装内容・コード・設計判断はすべて筆者本人のものですが、説明の構成や言い回しに AI 特有のクセが残っている可能性があります。バイブコーディング(AI ペアプロ駆動開発)の実践レポートとして、その点も含めて読んでもらえると幸いです。
はじめに
ほぼ毎朝03:45に起きて12kmを走る。最近始めたばかりの習慣だ。
走るのは習慣だが、記録を見せる仕組みは自分で作らなければならない。Stravaにはデータが蓄積されているので、それをリアルタイムでWebサイトに表示し、当日の結果はOG画像化してX (Twitter) へ自動投稿するところまで自動化した。この記事では、その技術構成について書く。Next.js 16 / React 19 / Tailwind v4 / Zod / Upstash Redis / Vercel Cron / next/og といった2026年時点の最新スタックでどう組んだかを共有する。
技術スタック(2026 年版)
| レイヤー | 採用 |
|---|---|
| フレームワーク | Next.js 16.2 (App Router / Turbopack) |
| UI | React 19.2 + Server Components |
| 言語 | TypeScript 5 (strict) |
| スタイリング | Tailwind CSS v4 (@tailwindcss/postcss) |
| アニメーション | Framer Motion 12 (motion/react + LazyMotion) |
| ランタイム検証 | Zod 4 |
| 永続化 | Upstash Redis (Vercel KV 後継) |
| 定期実行 | Vercel Cron |
| 画像生成 | next/og の ImageResponse |
| エラー監視 | Sentry (@sentry/nextjs) / widenClientFileUpload |
| 計測 | Vercel Speed Insights / Analytics |
| セキュリティヘッダ | CSP nonce + strict-dynamic(Next.js 16 の proxy.ts で) |
| データソース | Strava API v3 |
この実装の特徴は、二段構えのデータパイプラインにしたところだ。リアルタイム表示と履歴ダッシュボードでは要件がまったく違うので、層を分けて設計している。
アーキテクチャ概観
┌──────────────┐ refresh_token ┌─────────────────┐
│ Strava API │◀─────────────────────│ Vercel Edge │
└──────┬───────┘ │ (proxy.ts: CSP)│
│ └────────┬────────┘
│ activities (JSON) │
▼ ▼
┌─────────────────────┐ unstable_cache ┌──────────────────┐
│ lib/strava.ts │─────────────────▶│ Server Component│
│ (today-only fetch) │ revalidate:300 │ (Hero / Panel) │
└─────────────────────┘ └──────────────────┘
▲ 07:00 JST 1日1回
┌──────┴────────────────┐
│ /api/cron/post-run │ ┌───────────────┐
│ ├─ Strava → KV sync │──▶│ Upstash Redis │ (48h TTL)
│ ├─ next/og で画像生成│ └───────────────┘
│ └─ X (v2 API) へ投稿 │
└───────────────────────┘
- リアルタイム層:トップページの「TODAY'S RUN」パネル。Server Component から Strava を直接叩き、
unstable_cacheで 5 分キャッシュ。 - 永続化層:
/log/graph用の年間ヒートマップ。Vercel Cron が 1 日 1 回、全アクティビティを Upstash Redis に書き込む。 - 配信層:当日結果は
ImageResponseで 1200×675 の PNG を生成し、X v2 API で自動投稿。
これにより、ホットパスでは1秒級のキャッシュ済みレスポンスを返しつつ、履歴データはKVから定数時間で読める構成になる。
Strava OAuth2 と Refresh Token フロー
個人利用なら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 の取得
/oauth/token は application/x-www-form-urlencoded が前提なので、URLSearchParams で送る(JSONで送れる実装とformでしか通らない実装が混在しており、ここでハマりやすい)。
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
返却された refresh_token を Vercel の環境変数に登録する。Strava は不定期に refresh_token をローテートするので、Cron 側で refresh_token の差分検知ログを ::notice:: 出力し、変わったらすぐ更新できるようにしてある。
Zod による Strava API レスポンスのランタイム検証
外部APIは型を信用してはいけない。レスポンスは必ずZodスキーマで safeParse し、型情報は z.infer でTypeScript型に落とす。
// lib/strava.ts
import { z } from "zod";
const StravaTokenResponseSchema = z.object({
access_token: z.string(),
refresh_token: z.string(),
expires_at: z.number(),
expires_in: z.number(),
token_type: z.string(),
});
const StravaActivitySchema = z.object({
id: z.number(),
name: z.string(),
distance: z.number(),
moving_time: z.number(),
elapsed_time: z.number(),
total_elevation_gain: z.number(),
type: z.string(),
sport_type: z.string(),
start_date: z.string(),
start_date_local: z.string(),
average_heartrate: z.number().optional(),
max_heartrate: z.number().optional(),
average_speed: z.number(),
map: z
.object({
summary_polyline: z.string().nullable(),
})
.optional(),
});
type StravaActivity = z.infer<typeof StravaActivitySchema>;
safeParse の戻り値で分岐して、フォールバックの EMPTY_RUN を返すパターンに統一した。any を排除しつつ、Strava側のスキーマ変更が起きたときに即座にログとして検知できる。
Server Component + unstable_cache でのリアルタイム取得
Next.js App RouterのServer Componentを使うと、ビルド時ではなくリクエスト時に環境変数とともに fetch を実行できる。server-only パッケージを併用してクライアントへの誤インポートを防ぐ。
// lib/strava.ts
import "server-only";
import { unstable_cache } from "next/cache";
const REVALIDATE_SECONDS = 300; // 5分キャッシュ
const HTTP_STATUS_UNAUTHORIZED = 401;
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 parsed = StravaTokenResponseSchema.safeParse(await res.json());
return parsed.success ? parsed.data.access_token : null;
}
const getCachedAccessToken = unstable_cache(refreshAccessToken, ["strava-access-token"], {
revalidate: REVALIDATE_SECONDS,
});
キャッシュ済み token が失効したときの 401 リトライ
unstable_cache は便利だが、5分間キャッシュされた access_token がStrava側の都合で先に失効するケースがある。素朴に書くと最大5分間リクエストが落ち続けるので、401のときだけ1回強制リフレッシュして再試行する最小限のリトライを入れた。
async function fetchActivitiesWithRetry(token: string, url: string): Promise<Response> {
const first = await fetchActivities(token, url);
if (first.status !== HTTP_STATUS_UNAUTHORIZED) return first;
// キャッシュ済み token が失効している場合のみ、再取得して 1 回だけ再試行する。
const refreshed = await refreshAccessToken();
if (!refreshed) return first;
return fetchActivities(refreshed, url);
}
守りたいのは三点。無限ループにしない、正常系のレイテンシを増やさない、失効が起きた最初のリクエストだけ救う。
当日アクティビティの集約 — 1 日複数本にも対応
Stravaは1走を1アクティビティとして返すが、朝ジョグ+インターバルのように1日に複数記録することもある。当日分は全部合算して、心拍平均は本数加重で算出する。
export async function getTodaysRun(): Promise<TodaysRun> {
const token = await getCachedAccessToken();
if (!token) return EMPTY_RUN;
const after = getTodayStartUnix(process.env.STRAVA_ATHLETE_TIMEZONE ?? "Asia/Tokyo");
const url = `${STRAVA_ACTIVITIES_URL}?after=${after}&per_page=30`;
const res = await fetchActivitiesWithRetry(token, url);
const parsed = z.array(StravaActivitySchema).safeParse(await res.json());
if (!parsed.success) return EMPTY_RUN;
const runs = parsed.data.filter(isRun);
if (runs.length === 0) return { ...EMPTY_RUN, fetchedAt: new Date().toISOString() };
const totalDistanceM = runs.reduce((s, a) => s + a.distance, 0);
const totalMovingSec = runs.reduce((s, a) => s + a.moving_time, 0);
const totalElevM = runs.reduce((s, a) => s + a.total_elevation_gain, 0);
// 心拍は記録があるものだけで平均
const hrRuns = runs.filter(
(r): r is StravaActivity & { average_heartrate: number } =>
typeof r.average_heartrate === "number" && r.average_heartrate > 0,
);
const avgHr =
hrRuns.length > 0 ? hrRuns.reduce((s, r) => s + r.average_heartrate, 0) / hrRuns.length : null;
return {
hasRun: true,
distanceKm: totalDistanceM / 1000,
movingTimeSec: totalMovingSec,
paceSecPerKm: totalMovingSec / (totalDistanceM / 1000),
elevationGainM: totalElevM,
averageHeartrate: avgHr,
activityCount: runs.length,
polyline: runs[runs.length - 1].map?.summary_polyline ?? null,
fetchedAt: new Date().toISOString(),
// ...
};
}
タイムゾーンは環境変数で切り替えられるようにし、getTodayStartUnix でオフセット演算によりJST 0時のUTC unix秒を確定させる。Date#toLocaleDateString をサーバー側で使うと実行環境のTZに引っ張られるため、こうした境界では必ずオフセット演算で書く。
Vercel Cron + Upstash Redis で全履歴を永続化
リアルタイム層は当日分しか持たないが、年間ヒートマップやサマリには過去の全履歴が必要だ。これを毎リクエストStravaから引くのは現実的でないので、Vercel Cronで1日1回 Upstash Redis に書き込む構成にした。
vercel.json:
{
"crons": [
{ "path": "/api/cron/daily-checkin", "schedule": "45 18 * * *" },
{ "path": "/api/cron/post-run", "schedule": "0 22 * * *" }
]
}
Vercel Hobbyプランは Cron 2本までという制約がある。だから
post-runCron 1本の中で「Strava 同期 → 当日結果のOG画像生成とX投稿 → 日曜なら週次草グラフ投稿 → GitHubプロフィール同期」までを連結ディスパッチしている。
KV書き込みの本体はこれだけ。未設定ならローカルJSONにフォールバックし、開発環境ではKVなしでも動く設計にしている。
// lib/running-log-store.ts
import { Redis } from "@upstash/redis";
const KV_KEY = "running-log";
const TTL_SECONDS = 48 * 60 * 60; // 48h(Cron 2 連続失敗まで耐える)
let _redis: Redis | null | undefined;
function getRedis(): Redis | null {
if (_redis !== undefined) return _redis;
const url = process.env.UPSTASH_REDIS_REST_URL;
const token = process.env.UPSTASH_REDIS_REST_TOKEN;
if (!url || !token) return (_redis = null);
return (_redis = new Redis({ url, token }));
}
export async function saveRunningLogToKv(logs: readonly DayLog[]): Promise<boolean> {
const redis = getRedis();
if (!redis) return false;
await redis.set(KV_KEY, logs, { ex: TTL_SECONDS });
return true;
}
読み出し側は KV → 静的JSONフォールバックの単方向に統一。
// lib/running-log.ts
export async function getRunningLog(): Promise<readonly DayLog[]> {
try {
const { loadRunningLogFromKv } = await import("./running-log-store");
const kvLogs = await loadRunningLogFromKv();
if (kvLogs && kvLogs.length > 0) return kvLogs;
} catch {
console.warn("[running-log] KV read failed, falling back to static JSON");
}
return RUNNING_LOG_2026; // content/running-log.json
}
KVを未設定のままでもアプリが動く構成は、開発体験上重要だ。これがないと、ローカルで毎回シークレット同期しないとプレビューが壊れる状況になる。
Polyline デコードと SVG レンダリング:地図ライブラリを使わない選択
StravaはGoogleの Encoded Polyline Algorithm Format で走行ルートを返す。これを自前でデコードして、外部地図タイルを使わずSVG <polyline> で描画した。react-leaflet も mapbox-gl も使わない。
export 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;
}
採用理由は次の通り。
- JSバンドルが軽い:地図ライブラリは合計で 100KB+ クラス、SVGなら数百行で済む。
- Server Componentのまま描ける:
'use client'不要、ハイドレーション不要。 next/ogのImageResponseでも同じ関数で使える:UIとOG画像でロジックを共有できる。- デザイン上の狙いにも合う:純黒背景×ネオンイエローグリーンのルートシルエットだけ、というミニマル表現に向く。
next/og で当日リザルトを画像化 → X へ自動投稿
next/og の ImageResponse は、JSXを書くだけで Vercel Edge ランタイム上でPNGが生成できるAPI(内部はSatori + Resvg)。これをCronから叩く。
// lib/og/today-run.tsx
import { ImageResponse } from "next/og";
const WIDTH = 1200;
const HEIGHT = 675; // Twitter 単一画像の推奨 16:9
const BG = "#000000";
const SIGNAL = "#e2ff3a";
export function renderTodayRunImage(run: TodaysRun): ImageResponse {
return new ImageResponse(
<div style={{ width: "100%", height: "100%", background: BG, display: "flex" /* ... */ }}>
<span style={{ fontSize: 280, fontWeight: 900, color: SIGNAL }}>
{run.distanceKm.toFixed(2)}
</span>
{/* ペース、タイム、心拍、獲得標高、SVG ルート */}
</div>,
{ width: WIDTH, height: HEIGHT },
);
}
Cron側ではこれを arrayBuffer に変換し、X v2 API へメディアアップロードしてからツイートを発射する。
// app/api/cron/post-run/route.ts (抜粋)
const image = renderTodayRunImage(run);
const imageBytes = new Uint8Array(await image.arrayBuffer());
const upload = await uploadImageMedia(imageBytes, "image/png", buildTodayRunImageFileName({ run }));
const mediaIds = upload.ok && upload.mediaId ? [upload.mediaId] : undefined;
const tweet = await postTweet(text, mediaIds ? { mediaIds } : {});
ブラウザで使っているSVGルート描画ロジック (decodePolyline) を、そのままOG画像でも使い回せる。WebサイトとSNSで表示される図が、コードレベルで一致する。
なお
ImageResponseは Latin + 記号のみで構成すれば日本語フォントを同梱せずに済む。OG画像内のラベルはすべてTODAY'S RUN/KM/PACE/TIME/HR AVG/ELEVのような英語表記にして、フォントを軽くしている。
CSP nonce + strict-dynamic(Next.js 16 の proxy.ts)
セキュリティヘッダはホワイトリストだけでは厳しくなりつつある。リクエストごとにnonceを発行し、strict-dynamic でnonce付きスクリプトの子孫だけを信頼する形に寄せた。Next.js 16 では middleware.ts が proxy.ts にリネームされた(実体は同じ Edge ランタイム)。
// proxy.ts
import { NextResponse, type NextRequest } from "next/server";
import { buildContentSecurityPolicy, createCspNonce } from "@/lib/csp";
export function proxy(request: NextRequest): NextResponse {
const isDev = process.env.NODE_ENV === "development";
const nonce = createCspNonce();
const csp = buildContentSecurityPolicy(nonce, isDev);
const requestHeaders = new Headers(request.headers);
requestHeaders.set("x-nonce", nonce);
const res = NextResponse.next({ request: { headers: requestHeaders } });
res.headers.set("Content-Security-Policy", csp);
return res;
}
export const config = {
matcher: [
{
source: "/((?!api|_next/static|_next/image|favicon.ico).*)",
missing: [
{ type: "header", key: "next-router-prefetch" },
{ type: "header", key: "purpose", value: "prefetch" },
],
},
],
};
// lib/csp.ts
export function buildContentSecurityPolicy(nonce: string, isDev: boolean): string {
const devEval = isDev ? " 'unsafe-eval'" : "";
return `
default-src 'self';
script-src 'self' 'nonce-${nonce}' 'strict-dynamic'${devEval} https://www.googletagmanager.com ...;
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
img-src 'self' blob: data: ...;
media-src 'self' blob: https://*.public.blob.vercel-storage.com;
object-src 'none';
base-uri 'self';
frame-ancestors 'none';
worker-src 'self' blob:;
connect-src 'self' ...;
`
.replace(/\s{2,}/g, " ")
.trim();
}
export function createCspNonce(): string {
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes); // Edge ランタイムでも動く
return btoa(String.fromCharCode(...bytes));
}
実装上の要点を3つ挙げる。
script-srcから'unsafe-inline'を完全に外している。Next.js が自動挿入するインライン script にも nonce が伝播する。- nonce は
crypto.getRandomValuesで生成。Edge ではBufferが使えないのでbtoa(String.fromCharCode(...))で base64 化する。 media-srcにhttps://*.public.blob.vercel-storage.comを許可しているのは、Hero の動画を Vercel Blob から配信しているため。
Tailwind CSS v4 + Framer Motion (LazyMotion)
Tailwind CSS は v4 系で PostCSS プラグインに切り替えた(@tailwindcss/postcss)。設定は CSS 側で @theme を使って書ける時代になり、tailwind.config.ts がほぼ消えた。
Framer Motion は motion/react + LazyMotion + m コンポーネントで、初期バンドルから不要な機能を削っている。
// 初期バンドルを最小化したい場所では `m.*` を使う
import { m, useInView, animate } from "framer-motion";
const EASE: [number, number, number, number] = [0.22, 1, 0.36, 1];
<m.div
initial={{ opacity: 0, y: 40 }}
animate={inView ? { opacity: 1, y: 0 } : undefined}
transition={{ duration: 1, ease: EASE }}
/>;
prefers-reduced-motion: reduce を尊重するため、命令的 animate() を使う箇所では明示的にチェックして即時終値を表示するようにしている(MotionConfig の reducedMotion はあくまで宣言的 API 向け)。
観測性 — Sentry / Speed Insights / Analytics
@sentry/nextjs は withSentryConfig で next.config.ts をラップするだけで、ソースマップ自動アップロード、Vercel Cron Monitor の自動計装、tunnelRoute でのアドブロック回避まで揃う。
// next.config.ts
export default withSentryConfig(nextConfig, {
org: "shotaro",
project: "0345runner",
silent: !process.env.CI,
widenClientFileUpload: true,
tunnelRoute: "/monitoring",
webpack: {
automaticVercelMonitors: true,
treeshake: { removeDebugLogging: true },
},
});
Cron が致命エラーで終わると Sentry.captureMessage(..., "error") で 502 と一緒に通知が飛ぶ。投稿が来ていないことに翌日昼ごろ気づく代わりに、Sentry のプッシュ通知で寝起きに気づけるようになった。
まとめ:このスタックで何が変わったか
実際に動いている要素を並べると、こうなる。
- Next.js 16 / React 19 / Tailwind v4 / Turbopack で 2026 年時点の最新ビルドパイプライン
- Zod でランタイム検証された型安全な Strava API クライアント
- Server Component +
unstable_cache+ 401 単発リトライのリアルタイム層 - Vercel Cron + Upstash Redis + 静的 JSON フォールバックの永続化層
next/ogのImageResponseで生成した PNG を X v2 API で自動投稿- CSP nonce +
strict-dynamicをproxy.ts(Next.js 16 の旧 middleware) で発行 - Sentry + Speed Insights で本番観測
これらが繋がって、朝走ると数時間後にはWebサイトとタイムラインに自動で結果が出るパイプラインになった。毎朝03:45に起きて走るアナログな習慣の上に、最新のWebスタックで自動化された記録レイヤーが乗る。これが、自分にとってのバイブコーディングだ。
[ Next Action / 次の動き ]
走り続ける記録を、もっと観る。
読了ありがとうございます。次は映像、または生データへ。