Nuxt 4:SSR + 全ページ prerender + Critical CSS inlining の落とし穴

Nuxt 4 で SSR を有効にし、公開ページを全ページ prerender したうえでクリティカル CSS をインライン化する: render:response + Beasties → unhead / Nuxt UI の inline style と競合 ❌ vite-plugin-beasties → Vite のエントリ HTML のみ処理し、ルートごとの prerender 成果物は対象外 ❌ nitro:build:public-assets + Beasties ✅
更新
architecture

💡 背景

本ブログは Nuxt 4 + Nuxt Content + Nuxt UI で構築しています。本番環境のデプロイモデルは次のとおりです。

  • ssr: true — SSR を維持し、開発体験と動的ルーティングに影響を与えない
  • 公開コンテンツページはビルド時に全ページ prerender し、初回表示は静的 HTML と同等にする

この前提のもとでクリティカル CSS をインライン化する方法として、次の 3 つが妥当に見えました。

  1. render:response + Beasties — SSR レスポンス段階で HTML を書き換える
  2. vite-plugin-beasties — Vite ビルド段階で処理する
  3. nitro:build:public-assets で prerender 成果物に Beasties を実行する

本記事では、最初の 2 つがなぜうまくいかないのか、3 つ目がなぜ成立するのか、そして data-beasties-container による hydration の落とし穴を含む実装の詳細をまとめます。

❌ 最初の 2 つがうまくいかない理由

1. render:response + Beasties

render:response は Nitro の SSR レスポンスフックで、サーバーサイドレンダリング完了後・レスポンス送信前に実行されます。ビルド時の prerender 中にここへ Beasties を載せると、次の問題が起きます。

  1. unhead リスク — Beasties は HTML 文字列を直接書き換えます(inline <style> の挿入、<link> の変更)。これは unhead<head> を管理するパイプラインの外で行われるため、出力順序が保証されず、クライアント hydration 時に <head> 構造の乱れやスタイルの重複が起きやすくなります。
  2. Nuxt UI との競合 — Nuxt UI は #nuxt-ui-colors などの inline style でテーマ色を書き込みます。Beasties による <head> の書き換えがこれとぶつかり、hydration の不一致をさらに悪化させます。

2. vite-plugin-beasties

このプラグインは Vite ビルド段階で動作し、Vite が出力する HTML を処理します。Nuxt + Nitro のビルドチェーンでは、Vite がクライアントバンドルを先に完了し、その後 Nitro がルートごとに独立した .html を prerender します。

プラグインが扱えるのは Vite のエントリ HTML だけで、Nitro prerender 後のマルチページ成果物ではありません。Vite が Nitro prerender より先に走るため、エントリ HTML を処理しても、最終的な各ページの prerender 成果物とは一致しません。

nitro:build:public-assets + Beasties が問題ない理由

本質的な違いは、SSR レスポンスチェーン内で HTML を書き換えず、prerender がすべてディスクに書き終わったあと、静的成果物を後処理する点にあります。

  1. タイミングが合う — Nitro がルートごとに prerender し、各ページの .htmloutput.publicDir に書き込んだあと、nitro:build:public-assets が実行されます。Beasties が読むのは CDN に載せる最終 HTML であり、Vite のエントリテンプレートでも prerender 前の中間成果物でもありません。
  2. カバレッジが完全prerender:generate で成功した .html パスを記録し、ファイル単位で処理します。多言語コンテンツページ、一覧ページ、詳細ページそれぞれの prerender 成果物が対象になるため、「エントリ HTML だけ最適化された」問題を避けられます。
  3. SSR のホットパスを変えない — module は dev では完全にスキップします。本番でも prerender されていない動的ルートはこのフックに触れません。Beasties は登録済みの静的ページだけで動き、開発時とランタイム SSR の挙動はそのままです。
  4. unhead / Nuxt UI との競合を回避 — 最初の 2 つの問題は、head パイプラインが安定する前に <head> を書き換えることが原因です。ビルド後処理では SSR が完了し、unhead と Nuxt UI の inline style がすでにファイルに書き込まれた静的 HTML を最適化します。レスポンス段階の head 管理と順序を奪い合いません。
  5. 失敗を局所化できる — 1 ページの Beasties 処理が失敗しても warning のみで、ビルド全体は止まりません。外部 CSS は HTML に残るため、最低限フルスタイルのフォールバックがあります。

こうして責務が分離されます。SSR / prerender が正しい HTML を生成し、Beasties はビルド末期に静的 HTML の初回表示を加速する — 「公開ページを全ページ prerender し、初回表示を静的 HTML 相当にする」というデプロイ目標と一致します。

🛠️ 実装

前提:ssr: truenitro.prerender の併用。 dev は通常の SSR、本番の公開ページはビルド時に静的化します。

構成は 4 つ — Nuxt / Nitro 設定、Module 連携、Beasties 設定、hydration の整合です。

1. Nuxt / Nitro 設定

nuxt.config.ts の prerender 関連設定(本番のみ有効。dev では routeRules を空にし、ローカルデバッグへの干渉を避ける):

ssr: true,
routeRules: isDev
  ? {}
  : {
      "/**": { prerender: true }, // 本番環境で公開ページを全ページ prerender
      "/_vercel/image": { prerender: false }, // Vercel 画像最適化ルート — 明示的に除外
    },

nitro: {
  prerender: {
    // prerender 済み HTML 内のサイト内 <a href> を走査し、リンクを自動追跡
    crawlLinks: true,
    // HTML 内に /_vercel/image?… が現れる場合(LCP preload など)— 誤 prerender を防ぐ
    ignore: [/^\/_vercel\/image/],
  },
},

2. Module 設定

modules/build-prerender.ts は dev 以外で次の 3 つを行います。

  1. prerender:routes コンテンツルートを prerender リストに追加するため、collectPrerenderRoutes() を呼び出します。crawlLinks: true の自動発見に加え、各 locale の静的ページと非 draft 記事を明示的に収集します。多言語サイトでは content ファイルパスと公開 URL が一致しないことが多く、crawler だけではページ漏れが起き得ます。
  2. prerender:generate 成功した .html パスを generatedFiles に記録します。
  3. nitro:build:public-assets 記録した generatedFiles を走査し、prerender 成功した HTML にだけ Beasties を実行します。

直感に反する前提が 1 つあります。公式ドキュメントでは「public assets のコピー後、Nitro server ビルド完了前」と説明されていますが、ここでの server ビルド は本番 server を rollup でバンドルする工程を指し、Nitro ライフサイクル全体ではありません。実際の順序は prerender 完了 → HTML がディスクに落ちる → このフック、なので Beasties が読むのは最終 prerender 成果物です。

Beasties の入力はディスク上の最終 HTML です。generatedFiles が空ならスキップし、ファイル単位の失敗は warning のみでビルドは継続します。

主要なコード構造:

// prerender 成功でディスクに書き込まれた .html の相対パスを記録
const generatedFiles = new Set<string>();

// crawlLinks が漏らしうるコンテンツルートを補完(multi-locale、非 draft 記事など)
nuxt.hooks.hook("prerender:routes", async ({ routes }) => {
  const discovered = await collectPrerenderRoutes(nuxt.options.rootDir);
  for (const route of discovered) {
    routes.add(route);
  }
});

// Nitro インスタンス待ち — 初期化後に prerender:generate をマウント
nuxt.hooks.hook("nitro:init", (nitro) => {
  // prerender 成功ごとに .html パスを登録
  nitro.hooks.hook("prerender:generate", (route) => {
    if (!route.fileName?.endsWith(".html") || route.error) return;
    generatedFiles.add(withoutLeadingSlash(route.fileName));
  });
});

// prerender 完了・public assets コピー後、ファイル単位でクリティカル CSS をインライン化
nuxt.hooks.hook("nitro:build:public-assets", async (nitro) => {
  const beasties = new Beasties({
    path: nitro.options.output.publicDir,
    publicPath: nitro.options.baseURL,
    ...beastiesConfig,
  });

  for (const file of generatedFiles) {
    const htmlPath = resolve(nitro.options.output.publicDir, file);
    const contents = await readFile(htmlPath, "utf-8");
    const processed = await beasties.process(contents);
    await writeFile(htmlPath, processed); // ディスク上の prerender 成果物を上書き
  }
});

3. Beasties 設定

config/beasties.ts の設定は保守的です。初回表示を加速しつつ、外部 CSS を唯一のソースにしません。各 false の理由は行内コメントに記載しています。

export const beastiesConfig = {
  // クリティカル CSS のみインライン化し、外部 CSS の <link> タグは変更しない —
  // Nuxt/unhead の head hydration との競合を避ける
  preload: false,

  // 外部 entry.css からインライン化済みのルールを削らない —
  // クライアントナビゲーションとランタイムスタイルはフル CSS に依存
  pruneSource: false,

  // @font-face をインライン化せず、Beasties 経由でフォントを preload しない —
  // フォントは既存の CSS / リソース戦略に任せる
  fonts: false,

  // 外部 <link> 由来の style のみ処理し、SSR 既存の inline style(Nuxt UI の #nuxt-ui-colors など)をスキップ
  reduceInlineStyles: false,
};

4. data-beasties-container と hydration

Beasties は data-beasties-container でクリティカル CSS の評価範囲を狭められます。公式では <body> 内でファーストビューに見えるコンテンツを包む最外層コンテナ に付けることを推奨しており、そのサブツリーに対してだけ CSS セレクタをマッチさせ、大きなページや深くネストしたページのビルド時間短縮に使えます。

属性が見つからない場合、Beasties は <html> にフォールバックし、data-beasties-container を自動書き込みします — 実質的にドキュメント全体を評価するのと同じです。

本プロジェクトでは app/app.vue<html> に設定しています。主目的は範囲の限定ではなく hydration の整合 です。

  • Beasties が prerender HTML を処理したあとも、出力に属性が残る
  • SSR テンプレート側に同じ属性がなければ、クライアント hydration で DOM 差分が起きうる

将来ページが長くなり、ビルド時 Beasties の処理時間が増えた場合は、<html> のままにせず、<body> 内のファーストビューコンテナ(hero + ナビを包む外側 div など)へ data-beasties-container を移す方が適切です。

app/app.vueuseHead 関連部分:

<script setup lang="ts">
  useHead(() => ({
    htmlAttrs: {
      // Beasties がビルド時 prerender で書き込む属性 — SSR 出力と一致させる
      "data-beasties-container": "",
    },
  }));
</script>

📋 まとめ

SSR 有効 + 公開ページ全ページ prerender の前提でクリティカル CSS をインライン化する際、要点は「Beasties が使えるか」ではなく、いつ、どの HTML に対して実行するか です。

うまくいかない 2 つの方法:

  • render:response + Beasties — SSR レスポンスチェーンで <head> を直接書き換え、unhead パイプラインの外で動作。Nuxt UI の inline style(#nuxt-ui-colors など)と競合し、hydration リスクが高い。
  • vite-plugin-beasties — Vite ビルドが Nitro prerender より先。プラグインは Vite エントリ HTML のみ処理し、ルートごとの prerender 成果物をカバーできない。

成立する方法: nitro:build:public-assets で prerender 済み HTML に Beasties を実行 — dev では module をスキップ、本番でも SSR ホットパスには触れない。SSR / prerender が正しいページを生成し、Beasties はデプロイ成果物の初回表示だけを加速する。

検証: npm run build 後、.output/public/index.html に inline <style> と残存する外部 stylesheet の <link> があることを確認。ビルドログに Beasties processed N prerendered HTML file(s) が出力されること。

スコープ: Beasties が改善するのは CSS による初回描画のブロックのみです。画像 LCP、JS hydration の遅延、API キャッシュなどは別レイヤーでの最適化が必要です。