Nuxt 4:SSR + 全ページ prerender + Critical CSS inlining の落とし穴
💡 背景
本ブログは Nuxt 4 + Nuxt Content + Nuxt UI で構築しています。本番環境のデプロイモデルは次のとおりです。
ssr: true— SSR を維持し、開発体験と動的ルーティングに影響を与えない- 公開コンテンツページはビルド時に全ページ prerender し、初回表示は静的 HTML と同等にする
この前提のもとでクリティカル CSS をインライン化する方法として、次の 3 つが妥当に見えました。
render:response+ Beasties — SSR レスポンス段階で HTML を書き換えるvite-plugin-beasties— Vite ビルド段階で処理するnitro:build:public-assetsで prerender 成果物に Beasties を実行する
本記事では、最初の 2 つがなぜうまくいかないのか、3 つ目がなぜ成立するのか、そして data-beasties-container による hydration の落とし穴を含む実装の詳細をまとめます。
❌ 最初の 2 つがうまくいかない理由
1. render:response + Beasties
render:response は Nitro の SSR レスポンスフックで、サーバーサイドレンダリング完了後・レスポンス送信前に実行されます。ビルド時の prerender 中にここへ Beasties を載せると、次の問題が起きます。
- unhead リスク — Beasties は HTML 文字列を直接書き換えます(inline
<style>の挿入、<link>の変更)。これはunheadが<head>を管理するパイプラインの外で行われるため、出力順序が保証されず、クライアント hydration 時に<head>構造の乱れやスタイルの重複が起きやすくなります。 - 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 がすべてディスクに書き終わったあと、静的成果物を後処理する点にあります。
- タイミングが合う — Nitro がルートごとに prerender し、各ページの
.htmlをoutput.publicDirに書き込んだあと、nitro:build:public-assetsが実行されます。Beasties が読むのは CDN に載せる最終 HTML であり、Vite のエントリテンプレートでも prerender 前の中間成果物でもありません。 - カバレッジが完全 —
prerender:generateで成功した.htmlパスを記録し、ファイル単位で処理します。多言語コンテンツページ、一覧ページ、詳細ページそれぞれの prerender 成果物が対象になるため、「エントリ HTML だけ最適化された」問題を避けられます。 - SSR のホットパスを変えない — module は dev では完全にスキップします。本番でも prerender されていない動的ルートはこのフックに触れません。Beasties は登録済みの静的ページだけで動き、開発時とランタイム SSR の挙動はそのままです。
- unhead / Nuxt UI との競合を回避 — 最初の 2 つの問題は、head パイプラインが安定する前に
<head>を書き換えることが原因です。ビルド後処理では SSR が完了し、unhead と Nuxt UI の inline style がすでにファイルに書き込まれた静的 HTML を最適化します。レスポンス段階の head 管理と順序を奪い合いません。 - 失敗を局所化できる — 1 ページの Beasties 処理が失敗しても warning のみで、ビルド全体は止まりません。外部 CSS は HTML に残るため、最低限フルスタイルのフォールバックがあります。
こうして責務が分離されます。SSR / prerender が正しい HTML を生成し、Beasties はビルド末期に静的 HTML の初回表示を加速する — 「公開ページを全ページ prerender し、初回表示を静的 HTML 相当にする」というデプロイ目標と一致します。
🛠️ 実装
前提:ssr: true と nitro.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 つを行います。
prerender:routesコンテンツルートを prerender リストに追加するため、collectPrerenderRoutes()を呼び出します。crawlLinks: trueの自動発見に加え、各 locale の静的ページと非 draft 記事を明示的に収集します。多言語サイトでは content ファイルパスと公開 URL が一致しないことが多く、crawler だけではページ漏れが起き得ます。prerender:generate成功した.htmlパスをgeneratedFilesに記録します。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.vue の useHead 関連部分:
<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 キャッシュなどは別レイヤーでの最適化が必要です。