Next.js 16 個人ブログを爆速化した話 — 記事詳細を 3.8s → 73ms に縮めるまで
TL;DR
- 遅くて詰まってるポイント、人間が原因特定するのがまだ早い気がする
- Next.js 16 + Vercel + Prisma で動かしてるこのブログ(自分自身)を多段で速度改善した
- 主犯は 「ISR を宣言したのに効いてなかった」 こと →
generateStaticParamsで復活 - 記事詳細: 3.8s → 73ms(〜40倍) / weather API: 427ms → 49ms(〜9倍) などの効果
- Next.js 16 で
revalidateTagのシグネチャが変わってる地味な落とし穴も
環境
- Next.js 16.2.3(Turbopack)
- Vercel ホスティング
- Prisma + Supabase(DB + Auth + Storage)
- TypeScript / Tailwind CSS
やったこと総覧
| # | 打ち手 | 効果 |
|---|---|---|
| 1 | PostList のクエリを unstable_cache でキャッシュ | トップで毎リクエスト DB を叩かなくなった |
| 2 | getPostBySlug を React.cache で dedupe | リクエスト内の DB 往復 2 回 → 1 回 |
| 3 | renderMarkdown を unstable_cache で永続キャッシュ | probe-image-size の HTTP 往復スキップ |
| 4 | 投稿系 API で revalidateTag / revalidatePath | キャッシュ無効化を明示化 |
| 5 | EditButton の getUser → getSession 切り替え | auth/v1/user の 812ms が消滅 |
| 6 | 天気 fetch を requestIdleCallback で遅延 | 描画ブロック解消(装飾なので後で十分) |
| 7 | next.config.mjs に staleTimes 追加 | 「戻る」押下時の RSC 再取得 1.9s が消滅 |
| 8 | Yusei Magic を preload: false | woff2 preload 37 個 → 7 個 |
| 9 | generateStaticParams で全記事 SSG | 記事詳細 3.8s → 73ms |
| 10 | /api/weather を Edge Runtime + x-vercel-ip-* | 427ms → 49ms |
以下、特に学びが大きかったものをピックアップして書く。
① データ取得をキャッシュする(unstable_cache + React.cache)
トップページの「最近の戯言」セクションがアクセスごとに Prisma を叩いていた。unstable_cache で 1 日キャッシュ + tag で明示破棄できる構成にした。
// app/components/PostList.tsx
const getRecentPosts = unstable_cache(
async () => {
return prisma.post.findMany({
where: { published: true },
orderBy: { createdAt: "desc" },
take: 3,
include: { author: true, tags: { include: { tag: true } } },
});
},
["recent-posts"],
{ revalidate: 86400, tags: ["posts"] }
);更新時は POST/PUT のエンドポイントで revalidateTag("posts", "max") を呼んで明示破棄する。
⚠️ Next.js 16 から
revalidateTagの 第二引数が必須になっている。"max"は cacheLife プロファイル名で、永続キャッシュ相当の挙動。これに気付かずビルドが落ちた。Type error: Expected 2 arguments, but got 1. revalidateTag("posts");
また layout.tsx の generateMetadata と page.tsx の本体で同じ slug の findUnique を 2 回叩いていたので、React.cache で dedupe した。
// lib/queries.ts
import { cache } from "react";
export const getPostBySlug = cache(async (slug: string) => {
return prisma.post.findUnique({
where: { slug },
include: { author: true, tags: { include: { tag: true } } },
});
});cache() は 同一リクエスト内 で同じ引数なら結果を再利用する仕組み。ISR が効いていればそもそも DB は触らないが、キャッシュ再生成時にだけ効く保険として入れた。
② Markdown レンダリング結果を永続キャッシュ
renderMarkdown は内部で probe-image-size を使い、記事内画像の実サイズをリモートから HTTP で取得していた。これが画像枚数分走るので結構重い。
unstable_cache で (slug, updatedAt.toISOString()) をキーに永続キャッシュ化した。
// lib/markdown.ts
const renderMarkdownCachedInner = unstable_cache(
async (_slug: string, _updatedAtIso: string, md: string) =>
renderMarkdown(md),
["render-markdown"],
{ revalidate: false, tags: ["markdown"] } // false = 明示破棄まで永続
);
export function renderMarkdownCached(slug: string, updatedAt: Date, md: string) {
return renderMarkdownCachedInner(slug, updatedAt.toISOString(), md);
}updatedAt がキーに含まれるので、記事更新時は自動で別キーになり再計算される。古いキーのキャッシュは Next.js 側で放置される。
③ EditButton の 812ms 削減
ブラウザの DevTools で Network を見ていたら、記事ページに飛ぶたびに auth/v1/user への fetch が 812ms かかっていた。犯人はこれ。
// 改善前
useEffect(() => {
const supabase = createBrowserSupabaseClient();
supabase.auth.getUser().then(({ data: { user } }) => {
setIsLoggedIn(!!user);
});
}, []);supabase.auth.getUser() は Supabase の認証サーバーに毎回問い合わせるので遅い。一方 getSession() は localStorage / Cookie から既存セッションを読むだけでほぼ 0ms。
このボタンの用途は「ログイン中なら編集ボタンを表示する」だけ。実際に編集を実行する API ルートでは別途 getUser() で検証されるので、UI 表示判定としては getSession() で十分。
// 改善後
supabase.auth.getSession().then(({ data: { session } }) => {
setIsLoggedIn(!!session);
});ネットワークタブから auth/v1/user のリクエストごと消えた。
④ 装飾用 API は描画後でいい(requestIdleCallback)
背景の空の色を天気で変える装飾があり、/api/weather を即時 fetch していた。ただこれは装飾なので、メイン描画後で十分。requestIdleCallback でブラウザのアイドル時間に遅延した。
type IdleHandle = { type: "idle"; id: number } | { type: "timeout"; id: number };
function scheduleIdle(cb: () => void): IdleHandle {
if (typeof window === "undefined") return { type: "timeout", id: 0 };
const ric = (window as Window & {
requestIdleCallback?: (cb: () => void, opts?: { timeout: number }) => number;
}).requestIdleCallback;
if (ric) return { type: "idle", id: ric(cb, { timeout: 3000 }) };
return { type: "timeout", id: window.setTimeout(cb, 200) };
}requestIdleCallback は Safari の古いバージョンで未対応なので、setTimeout(200) でフォールバックしている。
⑤ 「戻る」ボタンで 1.9 秒 — staleTimes の落とし穴
記事詳細から「戻る」を押すと、トップに戻るのに毎回 1.9 秒かかっていた。DevTools で見ると /?_rsc=xxxx という URL に GET が飛んでいる。
これは React Server Components の payload を取得する Next.js の挙動。本来は Router Cache から即復元すべきところ、毎回再取得していた。
原因は Next.js 16 の Router Cache のデフォルト設定。
| ページ種別 | デフォルトの staleTimes |
|---|---|
| Static | 5 分 |
| Dynamic | 0 秒 ← 即 expire |
このサイトのトップは AboutMe が cookies() を使ってる関係で Dynamic 扱いになっており、Router Cache の保持時間が 0 秒だった。
next.config.mjs で明示的に拡張する。
const nextConfig = {
experimental: {
staleTimes: {
dynamic: 60,
static: 300,
},
},
};これで戻るボタン押下時に Router Cache から即復元されるようになり、_rsc の再取得が消えた。
⑥ フォントの preload が 37 個もあった
ネットワークタブで woff2 を見ると 37 個 の preload が走っていた。Yusei_Magic を subsets: ["latin"] で使っていたが、日本語フォントの Google Fonts CSS は unicode-range で大量の segment に分割されており、Next.js がそれらを全て自動 preload していた。
// 改善後
const yuseiMagic = Yusei_Magic({
weight: "400",
subsets: ["latin"],
preload: false, // ← 追加
});preload: false にすることで、<link rel="preload"> の自動生成が止まる。実際に表示する文字に必要な woff2 だけが後で読み込まれる。
| Before | After | |
|---|---|---|
| woff2 ファイル数 | 37 | 7 |
副作用として初回訪問時にフォントの一瞬のちらつき(FOUT)が出るが、woff2 は 1 年の immutable キャッシュが効くので 2 回目以降は気にならない。
⑦ 主犯 — ISR が効いていなかった
ここからが本題。記事詳細ページの HTML レスポンスに 3.8 秒かかっていた。revalidate = 86400 は宣言しているのに、なぜ。
真因調査
curl -I でレスポンスヘッダーを確認。
cache-control: private, no-cache, no-store, max-age=0, must-revalidate
x-vercel-cache: MISS
cf-cache-status: DYNAMIC連続で 5 回叩いても全部 MISS + age: 0。ISR キャッシュが完全に効いていないことが確定した。
毎回 Vercel Lambda が起動して Prisma + Markdown レンダリングして HTML を生成していた、ということ。
仮説 → 落とし穴
Next.js 16 で await params などが暗黙的に Dynamic Rendering を強制する挙動の可能性。force-static で打ち消そうとした。
// やってしまった
export const dynamic = "force-static";
export const revalidate = 86400;→ 動的ルート [slug] で generateStaticParams 未実装 + force-static の組み合わせは、リクエスト時に 404 を返す仕様。サイト全体の記事ページが 404 になり緊急 revert した(ヒヤッとした)。
Next.js の動作:
force-staticを指定した動的ルートはgenerateStaticParamsで事前生成された slug のみが有効。それ以外は 404。
正解: generateStaticParams
force-static を入れずに、generateStaticParams だけを追加する形にした。
// app/posts/[slug]/page.tsx
import { getAllPublishedSlugs } from "@/lib/queries";
export const revalidate = 86400;
export async function generateStaticParams() {
const slugs = await getAllPublishedSlugs();
return slugs.map((slug) => ({ slug }));
}// lib/queries.ts
export async function getAllPublishedSlugs(): Promise<string[]> {
const posts = await prisma.post.findMany({
where: { published: true },
select: { slug: true },
});
return posts.map(({ slug }) => slug);
}generateStaticParams を 明示的に書くことで「これは静的生成するルートだ」と Next.js に伝わり、ビルド時に全 published 記事が SSG される。新記事は dynamicParams = true(デフォルト)で初回アクセス時に都度生成 → 以降キャッシュ。
結果
デプロイ後の curl -I で連続フェッチした結果:
[1] TTFB=1.071s x-vercel-cache: HIT age: 0
[2] TTFB=0.341s x-vercel-cache: HIT age: 2
[3] TTFB=0.056s x-vercel-cache: HIT age: 3| Before | After | |
|---|---|---|
x-vercel-cache | MISS(5回連続) | HIT |
| TTFB(warm) | 0.24〜0.81s | 0.056s |
| HTML 全体取得 | 約 2.9〜3.8s | 約 73ms |
3.8 秒 → 73ms。約 40 倍になった。
⑧ Weather API を Edge Runtime 化
最後に、/api/weather の 427ms を縮めた。
改善前は 2 段階フェッチだった:
- クライアントが
ipapi.co/json/を叩いて緯度経度を取得 - クライアントが
/api/weather?lat=...&lon=...を叩く - サーバーが OpenWeatherMap を叩く
これを 1 段階に圧縮する。Vercel Edge Runtime は x-vercel-ip-latitude / x-vercel-ip-longitude ヘッダを自動注入するので、サーバー側で位置情報を取得できる。
// app/api/weather/route.ts
export const runtime = "edge";
export async function GET(request: NextRequest) {
const headerLat = request.headers.get("x-vercel-ip-latitude");
const headerLon = request.headers.get("x-vercel-ip-longitude");
const lat = parseFloat(headerLat ?? "");
const lon = parseFloat(headerLon ?? "");
// 0.1度(約 11km)に丸めて CDN キャッシュキーを共有しやすくする
const finalLat = Number.isFinite(lat) ? Math.round(lat * 10) / 10 : 35.6812;
const finalLon = Number.isFinite(lon) ? Math.round(lon * 10) / 10 : 139.7671;
const res = await fetch(
`https://api.openweathermap.org/data/2.5/weather?lat=${finalLat}&lon=${finalLon}&appid=${apiKey}`,
{ next: { revalidate: 600 } }
);
// ...
return NextResponse.json(data, {
headers: {
"Cache-Control":
"public, max-age=600, s-maxage=600, stale-while-revalidate=3600",
},
});
}ポイント:
runtime = "edge"で cold start ほぼゼロ- 緯度経度を 0.1 度に丸めることで、近所のユーザー同士で CDN キャッシュキーが共有される
- クライアントは引数なしで
/api/weatherを叩くだけ
クライアント側は ipapi.co への fetch を削除して 1 段階に。
結果
[1] TTFB=1.061s age: 0 (Edge cold start)
[2] TTFB=0.219s age: 0
[3] TTFB=0.049s age: 1 ← 49ms!| Before | After | |
|---|---|---|
| 外部 API 往復 | 2 回(ipapi.co + 自前 API) | 1 回のみ |
| ランタイム | Node | Edge |
| warm 時 TTFB | 427ms | 49ms |
| CDN キャッシュ | 効きにくい | 効く |
約 9 倍早くなった。
まとめ
| 改善対象 | Before | After | 倍率 |
|---|---|---|---|
| 記事詳細ページ | 3.8s | 73ms | 〜40× |
/api/weather | 427ms | 49ms | 〜9× |
| EditButton の認証チェック | 812ms | 消滅 | ∞ |
| 戻るボタン RSC 再取得 | 1.9s | 消滅 | ∞ |
| woff2 preload | 37 個 | 7 個 | 〜5× |
学び
revalidateを宣言しただけでは ISR は効かないことがある。x-vercel-cache: MISSが連発したら疑うforce-staticは単独だと動的ルートを壊す。generateStaticParamsとセットで使う- Next.js 16 で
revalidateTagの第二引数が必須になっている。"max"を渡せば従来挙動 - クライアントの認証チェックは
getSessionで十分なことが多い。getUserはサーバー側で - 装飾用の API は
requestIdleCallbackで遅延するとメインの描画を邪魔しない
残タスク
unstable_cacheを Next.js 16 安定化の'use cache'directive に移行するprobe-image-sizeの結果を DB に永続化する(投稿時のみ実行する設計)