32blogby Studio Mitsu

Next.js App RouterのTTFBを1秒→100msにした話

32blogのTTFBを1260ms→100msに改善した実体験。root layoutのheaders()が全ルートを静かに動的化する罠と、next-intl公式パターンに従った修正方法を解説。

by omitsu15 min read
目次

Next.js App RouterのサイトでTTFBが1秒前後、x-vercel-cache: MISSが毎回出ているなら、app/layout.tsxheaders()cookies()・next-intlのgetLocale()を呼んでいないか確認してほしい。root layoutでこれらのdynamic APIを呼ぶと、全ルートが静かに動的レンダリング扱いになる。generateStaticParamsdynamicParams = falseは無視される。 32blog.comでまさにこのデグレを踏んで2週間気付かなかった。原因特定の手順、ファイル3つで直せる修正、そして最初から踏み抜けるための検証コマンドをまとめる。

症状: 2週間気付かないまま TTFB 1.26 秒

32blog.comはWordPressからNext.jsに移行した。フレームワークの速度評価が好きで乗り換えたはずなのに、クリックするとなんとなく引っかかる感じが残っていた。「App Routerだしこんなもんか」と自分を納得させてそのまま運用していた。

全然そんなもんじゃなかった。

修正前の計測値:

bash
curl -s -o /dev/null -w "TTFB: %{time_starttransfer}s\n" https://32blog.com/ja/cli/cli-prompt-starship
# TTFB: 1.257s

レスポンスヘッダーが全部を物語っている:

cache-control: private, no-cache, no-store, max-age=0, must-revalidate
x-vercel-cache: MISS
x-vercel-id: hnd1::iad1::vxsv9-...

3つの赤信号がここに出ている:

  1. no-store — VercelのエッジCDNが明示的にキャッシュしない指示を受けている
  2. x-vercel-cache: MISS — 毎リクエストCDNを素通りしている証拠
  3. hnd1::iad1 — エッジPoPは東京だが、関数本体はiad1(米バージニア)で実行されている。日本からアクセスすると関数実行リージョンまで往復150〜180msが毎回乗る

.next/server/app/配下には 各記事のプリレンダーHTMLファイル自体は存在していた。ただランタイムで使われていないだけだった。

犯人: root layoutのheaders()

npm run buildの出力を見てルートの分類を確認した:

├ ƒ /[locale]                    ← Dynamic
├ ƒ /[locale]/[category]         ← Dynamic
├ ƒ /[locale]/[category]/[slug]  ← Dynamic (!?)

ƒは "server-rendered on demand"(リクエストごとにサーバー実行)の意味。でも記事ページは export const dynamicParams = false を書いてあるし、generateStaticParamsで440本×3言語すべて列挙している。本来なら(prerendered static HTML)になるはずだ。何かが上書きしている。

犯人は2週間前のコミットだった:

tsx
// app/layout.tsx — commit a126dc7「SEO修正: html lang動的化」
import { headers } from "next/headers";

export default async function RootLayout({ children }) {
  const headersList = await headers();          // ← この1行
  const lang = headersList.get("x-locale") ?? "ja";
  return (
    <html lang={lang}>
      <body>{children}</body>
    </html>
  );
}

動機は正当だった。<html lang="ja">がハードコードになっていて、/en/...のページもlang="ja"で配信されていた。GoogleはHTML lang属性を多言語SEOのシグナルとして使うので、ここを動的化するのは正しい改善だ。

実装が構造的にNGだった。しかもフレームワークがそれを教えてくれなかった。

なぜNext.jsはheaders()で動的化するのか

Next.js App Routerには明確なルールがある。レンダリングツリーでdynamic APIheaders()cookies()draftMode()searchParamsの読み込みなど)を呼ぶと、そのルート全体が動的レンダリングにオプトイン する。リクエスト固有の値に依存する出力はビルド時に事前生成できないので、フレームワークとしては当然の設計判断だ。

レビュー時に見落としていた波及効果:

  • root layoutは全ルートを包むapp/layout.tsxでdynamic APIを呼ぶと配下全部に伝播する。記事440本、カテゴリページ、タグページ、全部が動的化した
  • エラーも警告も出ないgenerateStaticParamsはビルド時に実行される。.next/server/app/**/*.htmlファイルも生成される。ただランタイムで使われない
  • npm run devは常に動的レンダリング。開発環境ではこの差分が一切見えない
  • LighthouseスコアがTTFB 1秒でも85点くらい出る。体感の遅さがパフォーマンスツール上で致命傷に見えない

可視化された症状はただ一つ、「なんとなくクリックが重い」という主観だけだった。

正しい構造: <html>[locale]/layoutに移す

正解は next-intl公式サンプル に明示されている。localeをHTTPヘッダーからではなく URLセグメントから取る 構造にする。

root layoutはpassthroughにする:

tsx
// app/layout.tsx
import { ReactNode } from "react";

// rootにnot-found.tsxがある以上、layoutファイル自体は必須だが、
// children をそのまま返すだけでよい。
export default function RootLayout({ children }: { children: ReactNode }) {
  return children;
}

locale layoutが<html>を持つ:

tsx
// app/[locale]/layout.tsx
import { setRequestLocale } from "next-intl/server";
import { hasLocale } from "next-intl";
import { notFound } from "next/navigation";
import { routing } from "@/i18n/routing";

export function generateStaticParams() {
  return routing.locales.map((locale) => ({ locale }));
}

export default async function LocaleLayout({
  children,
  params,
}: {
  children: React.ReactNode;
  params: Promise<{ locale: string }>;
}) {
  const { locale } = await params;
  if (!hasLocale(routing.locales, locale)) notFound();

  // 静的レンダリングを有効化する。localeはURL paramsから取るので
  // headers()は不要
  setRequestLocale(locale);

  return (
    <html lang={locale}>
      <body>{children}</body>
    </html>
  );
}

鍵は setRequestLocale。これは現在のレンダリングに対してnext-intlにlocaleを登録する関数で、HTTPヘッダーを読まない。つまりルートは静的生成の資格を保てる。

もう一つ注意点。root layoutがpassthroughになると、<html><body>は兄弟ルート(app/not-found.tsxなど)に供給されなくなる。それぞれ自前でラッパーを持つ必要がある:

tsx
// app/not-found.tsx
export default function NotFound() {
  return (
    <html lang="ja">
      <body>{/* 404 UI */}</body>
    </html>
  );
}

検証方法: デプロイチェックリストに入れるべき3コマンド

パフォーマンス関連のPRを出すたびに実行すべき検証手順。今回のデグレは全部これで即座に検出できていた。

1. ビルド出力のルート分類。コンテンツルートは(SSG)になっているべき。ƒ(Dynamic)が出ていたらおかしい:

bash
npm run build 2>&1 | grep -E "^(├|│|└)"

出力の最後の凡例に● (SSG) prerendered as static HTML (uses generateStaticParams)と書いてある。記事ルートがƒなら、どこかでdynamic APIが呼ばれている。

2. 本番TTFB。デプロイ後、地理的に関係のある場所から同じURLを2回以上叩く:

bash
for i in 1 2 3; do
  curl -s -o /dev/null -w "Run$i TTFB: %{time_starttransfer}s\n" https://your-site.com/some-page
done

初回はCDNのキャッシュ埋めで500ms前後出ることがある。2回目以降は100ms以下であるべき。毎回1秒超なら、CDNが素通りされている。

3. キャッシュヘッダー。静的配信されるページは、ウォームアップ後にx-vercel-cache: HITが出るべき:

bash
curl -sI https://your-site.com/some-page | grep -iE "cache-control|x-vercel-cache|x-vercel-id"

見るべきアンチパターン: cache-controlno-storeprivate、繰り返しアクセスでx-vercel-cache: MISSが継続、x-vercel-idiad1など関数実行リージョンが出続ける。

修正後の結果

変更したファイル:

  • app/layout.tsxreturn childrenに縮小
  • app/[locale]/layout.tsx<html><body>・fonts・GA等のScriptを全部引き受けた
  • app/not-found.tsx — 自前の<html>/<body>ラッパーを追加
  • ロケール配下の13ページにexport const dynamic = "force-static"を追加(念押し)

ビルド出力の変化。修正前は単一行のDynamicルートだけだった:

├ ƒ /[locale]/[category]/[slug]

修正後は全記事ぶんのSSGパスが展開される:

├ ● /[locale]/[category]/[slug]
│ ├ /ja/cli/cli-prompt-starship
│ ├ /en/cli/cli-prompt-starship
│ └ [+436 more paths]

本番TTFB(東京から計測):

ページ種別修正前修正後(cold)修正後(warm)
記事1257ms590ms100ms
ホーム1234ms107ms96ms
カテゴリ950ms585ms90ms

x-vercel-idhnd1::iad1(エッジ東京・関数米東)からhnd1のみに変わった。関数実行そのものがゼロ回 になり、Vercelエッジが事前生成HTMLを返すだけの状態になった。

他にも踏みやすい派生パターン

root layoutでのheaders()が最も破壊的だが、同じ構造で静かに動的化する経路は他にもある。要注意:

  • 共有layoutやmiddlewareでcookies() — 同じ伝播、同じ無言
  • ページレベルでのsearchParams読み込み — ページだけオプトインする。ただしホームや記事ページなど価値の高いルートが多いので影響は大きい
  • fetch(..., { cache: "no-store" }) — layoutやpageで1箇所あれば十分
  • レンダリング中のServer Actions呼び出し — コンテンツサイトでは稀だが知識としては必要
  • generateMetadata内でdynamic APIを読む — ページが読むのと同じ扱い
  • 内部でdynamic APIを呼ぶモジュールをimport — 一番見つけにくい。next-intl/servergetLocale()は内部でheaders()を読む。draftMode()も同様

有用な監査パターン: grep -rn "headers()\|cookies()\|draftMode()\|getLocale()" app/ --include="*.tsx"app/layout.tsxや共有レイアウト系コンポーネントのヒットは全部パフォーマンス地雷候補と疑ってかかる。

よくある質問(FAQ)

generateStaticParams + dynamicParams = false を書いていても動的化が上書きされるのはなぜ?

上書き関係ではない。dynamic API検出のほうが優先される。Next.jsのルール: ランタイムのレンダリングツリー内でdynamic APIが1つでも到達可能なら、generateStaticParamsが何を言おうとルートは動的レンダリングになる。静的HTMLはビルド時に生成される(だから.next/server/app/**/*.htmlは存在する)が、「使えないもの」として扱われる。

export const dynamic = "force-static" だけで直らないの?

ランタイムで実際にdynamic APIが呼ばれているなら直らない。force-staticは「このルートは静的だと誓います」という宣言で、ランタイムでheaders()等を呼ぶと例外を投げる仕組み。つまりアサーションとしては有用(バグを明示的に叩ける)が、本当の修正はdynamic API呼び出し自体を取り除くこと。

[locale]/layoutではなくroot layoutでlocaleが必要な場合は?

ほぼない。<html>を必要とするnon-localizedルート(root直下のnot-found.tsxなど)があれば、それぞれ自前で<html>ラッパーを持たせて、root layoutはpassthroughのままにする。next-intl 公式サンプル がまさにこの構成を採っている。

cacheComponents / 'use cache'が来たら解決する?

ゆくゆくは。Next.jsの新しいCache Componentsモデルは動的ページの一部だけ選択的にキャッシュできる仕組み。next-intl側の対応は issue #1493 で議論中でまだマージされていない。それまでは上の構成が本番で機能する唯一の解。

なぜCIを素通りしてデプロイされた?

CIの検査対象になっていなかった。TypeScriptは通る。lintは通る。ビルドは成功する。ルート分類のƒ/はビルドログに出るが見落としやすい。対策: ビルド出力をgrepして主要ルートがでなければCIを落とす1行チェックを入れる。地味だが効く。

middlewareを使っていると動的化する?

場合による。NextResponse.next({ request: { headers: modifiedHeaders } })のようにリクエストヘッダーを改変するmiddlewareは、マッチするルートを動的化させる傾向がある。リダイレクト・リライト・レスポンスcookieをセットするだけのmiddlewareは同じ伝播をしない。ホットパスでmiddlewareが何をしているかを監査する価値はある。

まとめ

SEO目的の1行の修正が、フレームワークによって「リクエストごとに<html lang>を決める」から「リクエストごとに全ページを再生成する」に暗黙のうちに拡張された。コストはTTFBが100msポテンシャルから1260msの実測値へ、12倍の遅延。440本の記事全部にこれが乗っていた。

修正自体はファイル3つ(root layout・[locale]/layoutnot-found)で済む。教訓はプロセス側にある。上で書いた3コマンド検証は事後対応ではなくApp Routerのデプロイチェックリストに標準装備すべきだ。

Next.js App Router + next-intl(あるいはヘッダーを触る任意のi18nライブラリ)を使っているなら、今すぐapp/layout.tsxを開いて、ヘッダー・クッキー・localeを解決するawaitが1つでもあるか見てほしい。その1行が、認識していないTTFBコストを払わせている可能性がある。