remix-v2-perf-ssr について
Remix v2 のパフォーマンス、サーバーサイドレンダリング、ストリーミング、キャッシュ、プリフェッチ、サーバーとクライアントの境界をレビューするスキルです。API 選択の指針を示し、描画やキャッシュの典型的な問題を扱います。
主な機能
- 01defer によるストリーミング
- 02HTTP キャッシュとルート headers
- 03サーバー・クライアントのモジュール分離
- 04clientLoader と clientAction の指針
- 05ハイドレーションの安全確認
- 06プリフェッチとアセットの事前読み込み
活用シーン
- 01遅い補助データの読み込み確認
- 02ドキュメントとデータのキャッシュ方針確認
- 03クライアントバンドル内のサーバー専用コード検出
- 04ハイドレーションとプリフェッチの監査
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ existential-birds/ beagle --skill remix-v2-perf-ssrインストール後、Claude Code で/remix-v2-perf-ssrを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
references/headers-caching.mdhydration.mdlinks-preload.mdprefetch.mdserver-client-split.mdstreaming.md詳細
Remix v2 のパフォーマンス、ストリーミング、キャッシュ、サーバー/クライアントの分離
Remix v2 には、組み込みの画像オプティマイザーも、仕組みが見えないフレームワークキャッシュもありません。すべてを標準の HTTP レイヤーに委ねています。パフォーマンスを支える柱は 4 つです。ストリーミング(defer/<Await>)、HTTP キャッシュ(headers エクスポート)、プリフェッチ(<Link prefetch> と <PrefetchPageLinks>)、そして厳密なサーバー/クライアントの分離(.server.*/.client.* ファイル規約)です。
クイックリファレンス
SWR を使った headers エクスポート(loader のヘッダーをドキュメントに引き継ぐ):
import type { HeadersFunction, LoaderFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
export async function loader({ params }: LoaderFunctionArgs) {
const post = await cms.getPost(params.slug);
return json(post, {
headers: {
"Cache-Control":
"public, max-age=60, s-maxage=3600, stale-while-revalidate=86400",
},
});
}
export const headers: HeadersFunction = ({ loaderHeaders }) => ({
"Cache-Control": loaderHeaders.get("Cache-Control") ?? "no-store",
});
サーバー専用モジュールには .server.ts を使用 — ファイルがクライアントの依存関係グラフに漏れると、ビルドは明確に失敗します:
// app/lib/db.server.ts — never bundled into the client
import { PrismaClient } from "@prisma/client";
export const db = new PrismaClient();
遅い追加データには defer() を使用:
import { defer } from "@remix-run/node";
import { Await, useLoaderData } from "@remix-run/react";
import { Suspense } from "react";
export async function loader({ params }: LoaderFunctionArgs) {
const product = await db.getProduct(params.id); // critical, awaited
const reviews = db.getReviews(params.id); // slow, not awaited
return defer({ product, reviews });
}
export default function Product() {
const { product, reviews } = useLoaderData<typeof loader>();
return (
<>
<ProductHeader product={product} />
<Suspense fallback={<ReviewsSkeleton />}>
<Await resolve={reviews} errorElement={<ReviewsError />}>
{(r) => <ReviewList reviews={r} />}
</Await>
</Suspense>
</>
);
}
defer と <Await> によるストリーミング
defer に渡すすべての Promise は、loader 内で await する前に作成する必要があります。そうしないと、loader は遅い呼び出しを待ち続け、ストリーミングの効果が得られません。<Await> には必ず errorElement を設定してください。設定しない場合、拒否された遅延 Promise はルートの ErrorBoundary に伝播してルート全体を終了させ、ストリーミングの利点が失われます。
詳しくは references/streaming.md を参照してください。
headers による HTTP キャッシュ
max-age はブラウザーキャッシュを制御し、s-maxage は共有キャッシュ/CDN キャッシュを制御します。CDN では s-maxage が max-age より優先されます。stale-while-revalidate を設定すると、CDN はバックグラウンドで更新している間、古いコンテンツを配信できます。ルートごとに 2 種類のキャッシュ対象があります。headers エクスポートで制御するドキュメントレスポンスと、クライアント側のナビゲーション時に送信される ?_data= JSON リクエストであるデータリクエスト(loader のレスポンスヘッダーで制御)です。それぞれ異なるポリシーを設定できますし、そうすべき場合もよくあります。
親/子ルートのマージでは「最も深いルートが優先」されます:デフォルトでは、一致したルートのうち最も深いルートの headers だけが実行されます。子ルートに headers エクスポートがない場合、Remix は headers をエクスポートしている最も近い親ルートまでさかのぼります。最も安全なのは、headers をリーフルートにだけ定義し、個人別の子ルートをラップするレイアウトには定義しないことです。そうしないと、親に設定した過度に積極的なポリシーによって、ユーザーごとに異なる HTML が CDN に気づかれないままキャッシュされます。
子ルートでマージする場合は、より小さい max-age を選んでください。子ルートから親のキャッシュポリシーを緩和してはいけません:
export const headers: HeadersFunction = ({ loaderHeaders, parentHeaders }) => {
const loader = parseCacheControl(loaderHeaders.get("Cache-Control"));
const parent = parseCacheControl(parentHeaders.get("Cache-Control"));
const maxAge = Math.min(loader["max-age"] ?? 0, parent["max-age"] ?? 0);
return { "Cache-Control": `private, max-age=${maxAge}` };
};
references/headers-caching.md を参照してください。
サーバー/クライアントの分離
コンパイラーは、loader、action、headers のエクスポートと、それらの内部で使用される依存関係をクライアントバンドルから除外します。ただし、これらの依存関係にモジュールの副作用がない場合に限ります。トップレベルの new PrismaClient()、console.log、initializeApp の呼び出しはいずれもツリーシェイキングを妨げます。ルールとして、node:fs、prisma、bcrypt、jsonwebtoken をインポートするモジュールや、process.env を読み取るモジュールには *.server.ts という名前を付けてください(または app/.server/ の下に配置してください。ディレクトリー形式には Remix Vite プラグインが必要です。Classic Compiler がサポートするのはファイル名のサフィックス形式のみです)。クライアントの依存関係グラフに到達するとビルドは明確に失敗するため、気づかれない漏出を防げます。
公開環境変数をブラウザーに渡すには、ルートの loader から window.ENV を設定するパターンを使用します。loader から process.env をそのまま返してはいけません。詳しくは references/server-client-split.md を参照してください。
clientLoader と clientAction
v2 では、ブラウザー上でサーバーの loader/action と並行して(またはその代わりに)実行される、任意の clientLoader/clientAction エクスポートが追加されました。デフォルトでは、clientLoader は初回のハイドレーション時には実行されません。サーバーの loader がページを SSR し、clientLoader が実行されるのは、その後のクライアント側ナビゲーション時のみです。初回のレンダリング時にも実行するには、clientLoader.hydrate = true を設定し、実行中に表示する HydrateFallback コンポーネントをエクスポートします:
import type { ClientLoaderFunctionArgs } from "@remix-run/react";
export async function loader() {
return json({ /* SSR data */ });
}
export async function clientLoader({ serverLoader }: ClientLoaderFunctionArgs) {
const cached = clientCache.get();
if (cached) return cached;
const fresh = await serverLoader<typeof loader>(); // round-trip to server loader
clientCache.set(fresh);
return fresh;
}
clientLoader.hydrate = true; // opt in to running on initial hydration
export function HydrateFallback() {
return <Skeleton />;
}
clientLoader は、サーバーペイロードのクライアント側キャッシュ、ハイドレーション後の IndexedDB/localStorage からの読み取り、完全なクライアント専用ルート(loader を完全に省略)に使用してください。ルートの loader が SSR したサーバーペイロードを再取得してはいけません。無駄な往復になるためです。代わりに serverLoader() を呼び出してキャッシュするか、遷移時のみ実行してください(hydrate は false のままにします)。
Hydration の安全性
useHydrated() は SSR 中とクライアントでの最初のレンダー時に false を返し、次のレンダーで true に切り替わります。この2段階の動作によって、HTML が一致した状態に保たれます。SSR を絶対に行わないコンポーネント(マップや window を参照するチャートなど)には、<ClientOnly fallback={...}> でラップしてください。SSR に対応した ID には React の useId() を使い、レンダー中に Math.random() や crypto.randomUUID() を使わないでください。
Hydration の不一致を検出するための grep 対象一覧:new Date(、Math.random(、crypto.randomUUID(、Date.now(、window.、document.、localStorage、sessionStorage、navigator.、ロケールを明示せずに使う Intl.DateTimeFormat()、Intl.NumberFormat、.toLocaleDateString、.toLocaleTimeString、.toLocaleString、コンポーネントの本体内にある process.env.、異なる JSX を生成する typeof window の三項演算子、DOM を変更するサードパーティのスクリプト、<body> にノードを挿入するブラウザー拡張機能。references/hydration.md を参照してください。
プリフェッチ
<Link prefetch> には4つのモードがあります:"none"(デフォルト)、"intent"(ホバー/フォーカス時)、"render"(レンダー時に即時)、"viewport"(スクロールして表示領域に入ったとき)。プリフェッチでは、アンカーの兄弟要素として <link rel="prefetch"> タグが挿入されます。プリフェッチタグが一時的に最後の子要素になるため、CSS では :last-child ではなく :last-of-type を使ってください。
見落としやすい注意点があります。ローダーに Cache-Control が設定されていない状態でホバー時にプリフェッチすると、ブラウザーがプリフェッチのレスポンスをキャッシュしないため、リクエスト数が倍になります。ローダーで Purpose: prefetch ヘッダーを検出し、Cache-Control: private, max-age=10 を返してください。references/prefetch.md を参照してください。
links によるアセットのプリロード
links エクスポートはドキュメントの head に <link> タグを挿入します。重要なフォントと CSS をプリロードし、次に表示する可能性が高いページのアセットをプリフェッチしてください。Remix には組み込みの画像オプティマイザーがありません。ビルド時に画像のサイズを調整し(sharp、unpic、remix-image)、必ず width/height を設定してください。references/links-preload.md を参照してください。
ゲート(判断の順序)
順番に回答してください。Pass は条件が真であることを意味します。同じ行の API を選び、そこで終了してください。
defer() とローダーでの await
- このデータは初回表示、メタタグ、または SEO に必要ですか(例:商品名、ページタイトル)?
- Pass → ローダーで
awaitし、json()で返します。終了。 - Fail → ステップ2へ。
- Pass → ローダーで
- この呼び出しは実際に遅いですか(50ミリ秒超、リージョン間のデータベース接続、外部 API など。メモリ内キャッシュは除く)?
- Pass → 未解決のプロミスを
defer()に渡し、<Suspense>と<Await errorElement={...}>でラップします。終了。 - Fail →
awaitします。高速なデータを遅延させると、ストリーミングのオーバーヘッドが増え、メリットがないままスケルトンが一瞬表示されます。
- Pass → 未解決のプロミスを
.server.ts と実行時の typeof window チェック
- このモジュールは
node:*、prisma、bcrypt、jsonwebtoken、fs、pathをインポートするか、トップレベルでprocess.envを読み取りますか?- Pass → ファイル名を
*.server.tsにするか、app/.server/の下に配置します(ディレクトリー形式には Remix Vite プラグインが必要です。Classic Compiler がサポートするのはファイル名のサフィックス形式のみです)。クライアントに漏れると、ビルドは明確に失敗します。終了。 - Fail → ステップ2へ。
- Pass → ファイル名を
- トップレベルの副作用なしに、このモジュールは
loader/action/headers内からのみ呼び出されますか?- Pass → 明確さを保つため、引き続き
.server.tsを推奨します。ツリーシェイキングで対応できる場合もありますが、確実ではありません。終了。 - Fail → ステップ3へ。
- Pass → 明確さを保つため、引き続き
- このコードは正当にアイソモーフィックですが、環境に応じて処理を分岐する必要がありますか(ロガー、機能フラグなど)?
- Pass → 関数本体内であれば
typeof window === "undefined"を使えます。モジュールのトップレベルでは決して使わないでください(不要な分岐が原因で、サーバー依存関係がクライアントの依存関係グラフに含まれる可能性があります)。
- Pass → 関数本体内であれば
<Link prefetch> モードの選択
- リンクは機密性が高い、コストがかかる、またはローダーに副作用がある(ログアウト、分析が計測されるページビュー、ミューテーションを引き起こすローダー)?
- はい →
prefetch="none"。終了。 - いいえ → ステップ 2。
- はい →
- ファーストビューに表示される、次にクリックされる可能性が高い重要なナビゲーションリンクですか?
- はい →
prefetch="render"。ローダー/JS/CSS が直ちにプリフェッチされます。終了。 - いいえ → ステップ 3。
- はい →
- リンクは長いリスト(テーブル行、検索結果、フィード)にありますか?
- はい →
prefetch="viewport"(スクロールして表示されたときに実行)または"intent"(ホバー時に実行)。長いリストでは"render"を使用しないでください。終了。 - いいえ → ステップ 4。
- はい →
- デフォルト:標準的なナビゲーション(ヘッダー、サイドバー、フッター)には
prefetch="intent"を使用します。
追加ドキュメント
- ヘッダーとキャッシュ:
HeadersFunctionのシグネチャ、loaderHeaders/parentHeaders/actionHeaders/errorHeaders、SWR のパターン、親子間のマージのセマンティクスについては、references/headers-caching.md を参照してください。 - ストリーミング:
defer()、<Await>、<Suspense>、abortDelay、エラー処理、CSP との相互作用については、references/streaming.md を参照してください。 - サーバー/クライアントの分割:
.server.*/.client.*(ディレクトリ形式には Remix Vite プラグインが必要です。Classic Compiler でサポートされるのはファイル名のサフィックスのみです)、環境変数の処理、window.ENVパターンについては、references/server-client-split.md を参照してください。 - ハイドレーション:
useHydrated、<ClientOnly>、useId、不一致の検索リストについては、references/hydration.md を参照してください。 - プリフェッチ:
<Link prefetch>のモード、<PrefetchPageLinks>、Purpose: prefetchヘッダーのテクニックについては、references/prefetch.md を参照してください。 - リンクのプリロード:
linksエクスポート、フォント/CSS のプリロード、画像に関するガイダンスについては、references/links-preload.md を参照してください。
比較:どの API を使うか
| 必要な処理 | API | モジュール |
|---|---|---|
| 時間のかかる補助データをストリーミングする | defer() + <Await> | @remix-run/node + @remix-run/react |
| ドキュメントのレスポンスを CDN にキャッシュする | headers エクスポート | ルートモジュール |
| データのレスポンスを CDN にキャッシュする | json()/Response の Cache-Control | ローダーの戻り値 |
| サーバー専用モジュール | *.server.ts ファイル名 | ファイル規約 |
| ブラウザー専用モジュール | *.client.ts ファイル名 | ファイル規約 |
| クライアントで公開環境変数を使う | ルートローダー経由の window.ENV | パターン |
| SSR 対応 ID | useId() | react |
| 1 つのコンポーネントで SSR を抑制する | <ClientOnly> | remix-utils/client-only |
| ハイドレーション後に分岐する | useHydrated() | remix-utils/use-hydrated |
| ホバー時にプリフェッチする | <Link prefetch="intent"> | @remix-run/react |
| レンダリング時にプリフェッチする(ファーストビュー) | <Link prefetch="render"> | @remix-run/react |
| プログラムでプリフェッチする | <PrefetchPageLinks page="/absolute/path"> | @remix-run/react |
| フォント/CSS をプリロードする | links エクスポート | ルートモジュール |