nextjs-static-shells について
nextjs-static-shells は、Next.js 16 開発者向けのパターンガイドです。静的シェルを中心に、リクエスト依存の処理を動的な領域へ分離する設計を解説します。キャッシュ、Suspense、RSC 境界、クライアント側の対話処理も扱います。
主な機能
- 01静的シェルと動的スロットの構成
- 02'use cache' によるキャッシュ管理
- 03Suspense による動的コンテンツのストリーミング
- 04キャッシュタグと有効期間の管理
- 05クライアントプロバイダーアイランドの構成
活用シーン
- 01静的コンテンツと個別化 UI を含むページの構築
- 02リクエスト処理を分離してルート全体の動的化を防ぐ
- 03旧キャッシュ設定や unstable_cache の移行
- 04RSC 境界と Suspense の使い方の確認
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ joelhooks/ joelclaw --skill nextjs-static-shellsインストール後、Claude Code で/nextjs-static-shellsを入力して読み込まれたか確認します。
同梱ファイルagents/openai.yamlassets/small-logo.svg
詳細
静的優先の Next.js 16 パターン
まず静的シェルを構築し、パーソナライズやリクエスト固有の動作が必要な箇所に、小さな動的領域を設けます。
- 静的シェル = 決定的でキャッシュ可能、初回描画が高速
- 動的領域 = Suspense でストリーミングする、分離されたリクエスト/ユーザー固有の動作
- クライアント側のインタラクティブ機能 = プロバイダーのアイランドで実現し、クライアント処理を全体に広げません
ルートアーキテクチャ:エントリー + 静的部分 + スロット
パターン
- エントリーコンポーネント(サーバー、リクエストを考慮)
- params/search/auth/session/cookies を読み取ります
- アクセスを検証し、ID を解決して、動的 props を準備します
- 静的レンダラー(サーバー、
'use cache')- 決定的なレイアウト/コンテンツをレンダリングします
- 動的 UI をスロット props(
ReactNode)として受け取ります
- 動的スロット
- エントリーコンポーネントから挿入します
- 静的シェル内でレンダリングする箇所を Suspense でラップします
この方法が有効な理由
- 静的シェルのキャッシュ可能性が保たれます
- 動的な動作が明示的かつ限定的になります
- ストリーミングにより UI の応答性が保たれます
- 意図せずルート全体が動的になる事態を防げます
import { Suspense, type ReactNode } from 'react';
type PageProps = { params: Promise<{ slug: string }> };
/** Request-aware server entry. */
export default async function PageEntry({ params }: PageProps) {
const { slug } = await params;
const staticData = await getStaticData(slug); // deterministic
const userData = await getUserData(); // request-dependent
const dynamicPanel = <PersonalizedPanel userData={userData} />;
return <PageStatic data={staticData} panel={dynamicPanel} />;
}
type PageStaticProps = {
data: StaticData;
panel?: ReactNode;
};
/** Cached static shell. Keep request-volatile reads out. */
async function PageStatic({ data, panel }: PageStaticProps) {
'use cache';
return (
<main>
<Hero data={data.hero} />
<Content data={data.content} />
<Suspense fallback={<PanelSkeleton />}>{panel}</Suspense>
</main>
);
}
Cache Components のセットアップと仕組み
有効化
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
cacheComponents: true,
}
export default nextConfig
従来の experimental.ppr フラグを置き換えます。
3 種類のコンテンツ
| 種類 | 特徴 | 例 |
|---|---|---|
| 静的 | 同期的で、純粋な計算 | <header><h1>Our Blog</h1></header> |
キャッシュ済み('use cache') | 非同期ですが、入力が同じなら決定的 | cacheLife('hours') を指定した db.posts.findMany() |
| 動的(Suspense) | 実行時/リクエスト固有で、最新の状態が必要 | cookies()、ユーザーセッション、通知 |
'use cache' のスコープレベル
// File level — entire module cached
'use cache'
export default async function Page() { /* ... */ }
// Component level
export async function CachedComponent() {
'use cache'
const data = await fetchData()
return <div>{data}</div>
}
// Function level
export async function getData() {
'use cache'
return db.query('SELECT * FROM posts')
}
cacheLife() によるキャッシュプロファイル
import { cacheLife } from 'next/cache'
async function getData() {
'use cache'
cacheLife('hours') // Built-in: 'default' | 'minutes' | 'hours' | 'days' | 'weeks' | 'max'
return fetch('/api/data')
}
// Or inline config:
async function getDataCustom() {
'use cache'
cacheLife({
stale: 3600, // 1h — serve stale while revalidating
revalidate: 7200, // 2h — background revalidation interval
expire: 86400, // 1d — hard expiration
})
return fetch('/api/data')
}
組み込みプロファイルの簡易指定:'use cache' のみ → stale 5 分/revalidate 15 分。'use cache: remote' → プラットフォームの KV。'use cache: private' → 実行時 API を許可します(コンプライアンス対応のための例外措置)。
キャッシュの無効化
import { cacheTag } from 'next/cache'
async function getProduct(id: string) {
'use cache'
cacheTag('products', `product-${id}`)
return db.products.findUnique({ where: { id } })
}
updateTag() — 即時に、同一リクエスト内で無効化します:
'use server'
import { updateTag } from 'next/cache'
export async function updateProduct(id: string, data: FormData) {
await db.products.update({ where: { id }, data })
updateTag(`product-${id}`) // caller sees fresh data
}
revalidateTag() — バックグラウンドで stale-while-revalidate を行います:
'use server'
import { revalidateTag } from 'next/cache'
export async function createPost(data: FormData) {
await db.posts.create({ data })
revalidateTag('posts') // next request sees fresh data
}
キャッシュキーの生成(自動)
キーは、ビルド ID + 関数の位置ハッシュ + シリアライズ可能な引数 + クロージャ変数から生成されます。unstable_cache のような手動の keyParts は不要です。
async function Component({ userId }: { userId: string }) {
const getData = async (filter: string) => {
'use cache'
// cache key = userId (closure) + filter (argument)
return fetch(`/api/users/${userId}?filter=${filter}`)
}
return getData('active')
}
'use cache' の内部に含められないもの
厳守事項: キャッシュ境界の内部に、リクエストごとに変動する値を含めてはいけません。
'use cache' 内で禁止されるもの | 理由 |
|---|---|
cookies()、headers() | リクエスト固有 |
searchParams | リクエスト固有 |
| セッション/認証の読み取り | ユーザー固有 |
| ヘルパー呼び出しに隠れたユーザーロジック | リクエストへの依存が見えない |
| リクエストのライフサイクルに紐づく副作用 | 非決定的 |
Math.random()、Date.now() | キャッシュ内ではビルド時に一度だけ実行されます |
対処法:外部に取り出し、引数として渡す
// Wrong — runtime API inside 'use cache'
async function CachedProfile() {
'use cache'
const session = (await cookies()).get('session')?.value // Error!
return <div>{session}</div>
}
// Correct — extract in entry, pass as prop
async function ProfilePage() {
const session = (await cookies()).get('session')?.value
return <CachedProfile sessionId={session} />
}
async function CachedProfile({ sessionId }: { sessionId: string }) {
'use cache'
// sessionId becomes part of cache key automatically
const data = await fetchUserData(sessionId)
return <div>{data.name}</div>
}
例外:リファクタリングが現実的でないコンプライアンス対応では、'use cache: private' によって cookies()/headers() を使用できます。
RSC 境界のルール
これらは静的シェルのパターンに直接関係します。
非同期のクライアントコンポーネントは無効です
クライアントコンポーネントを非同期にすることはできません。非同期にできるのはサーバーコンポーネントだけです。
// Bad
'use client'
export default async function UserProfile() {
const user = await getUser() // Cannot await in client
return <div>{user.name}</div>
}
// Good — fetch in server entry, pass data down
// page.tsx (server)
export default async function Page() {
const user = await getUser()
return <UserProfile user={user} />
}
// UserProfile.tsx (client)
'use client'
export function UserProfile({ user }: { user: User }) {
return <div>{user.name}</div>
}
シリアライズできない props は境界を壊します
サーバーからクライアントに渡す props は、JSON シリアライズ可能でなければなりません。
| 渡せないもの | 対処法 |
|---|---|
| 関数(Server Actions を除く) | クライアントコンポーネント内で定義します |
Date オブジェクト | サーバー側で .toISOString() を呼び出します |
Map、Set | Object.fromEntries()/Array.from() を使います |
| クラスのインスタンス | プレーンオブジェクトを渡します |
Server Actions('use server')はクライアントコンポーネントに渡すことができます。これらは例外です。
非同期パターン(Next.js 15 以降)
params、searchParams、cookies()、headers() はすべて非同期です。型を Promise<...> とし、エントリーコンポーネント内で await します。
type PageProps = {
params: Promise<{ slug: string }>
searchParams: Promise<{ query?: string }>
}
export default async function Page({ params, searchParams }: PageProps) {
const { slug } = await params
const { query } = await searchParams
// ...
}
params を必要とする同期的なクライアントコンポーネントでは、React.use() を使います:
import { use } from 'react'
export default function Page({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = use(params)
}
Suspense 境界の要件
useSearchParams には必ず Suspense が必要です
Suspense がない場合、ページ全体が CSR になります:
// Bad — entire page CSR bailout
'use client'
import { useSearchParams } from 'next/navigation'
export default function SearchBar() {
const searchParams = useSearchParams()
return <div>Query: {searchParams.get('q')}</div>
}
// Good — isolated in Suspense
import { Suspense } from 'react'
export default function Page() {
return (
<Suspense fallback={<SearchSkeleton />}>
<SearchBar />
</Suspense>
)
}
クイックリファレンス
| フック | Suspense が必要 |
|---|---|
useSearchParams() | 常に |
usePathname() | 動的ルートでは必要 |
useParams() | 不要 |
useRouter() | 不要 |
Provider Islands(クライアントプロバイダーを適切に配置する)
ルール
クライアントプロバイダーは、可能な限り下位に、インタラクティブ性が必要な場所にのみマウントします。
- 良い例:機能単位のプロバイダーアイランド
- 悪い例:機能ローカルの状態管理にグローバルなルートプロバイダーを使う
パターン
サーバーエントリーから型付きの初期状態を渡します。クライアントプロバイダーは 'use client' 境界内で解決します。フックはアイランド内に配置します。
'use client';
import { createContext, useContext, useMemo } from 'react';
type FeatureState = { enabled: boolean };
type FeatureContextValue = { state: FeatureState };
const FeatureContext = createContext<FeatureContextValue | null>(null);
export function FeatureProvider({
children,
initialState,
}: {
children: React.ReactNode;
initialState: FeatureState;
}) {
const value = useMemo(() => ({ state: initialState }), [initialState]);
return <FeatureContext.Provider value={value}>{children}</FeatureContext.Provider>;
}
export function useFeature() {
const ctx = useContext(FeatureContext);
if (!ctx) throw new Error('useFeature must be used within FeatureProvider');
return ctx;
}
静的シェルモデルでのデータ取得
判断ツリー
| 必要な処理 | パターン |
|---|---|
| サーバーコンポーネントでデータを読み取る | 直接フェッチする — API 層は不要 |
| UI からの更新 | Server Action('use server') |
| 外部 API/webhook/モバイルクライアント | Route Handler |
| クライアントコンポーネントでデータが必要 | サーバー親コンポーネントから渡す(推奨)、または Route Handler |
ウォーターフォールの回避
// Bad — sequential
const user = await getUser();
const posts = await getPosts();
// Good — parallel
const [user, posts] = await Promise.all([getUser(), getPosts()]);
// Better — streaming with Suspense (each section independent)
<Suspense fallback={<UserSkeleton />}><UserSection /></Suspense>
<Suspense fallback={<PostsSkeleton />}><PostsSection /></Suspense>
プリロードパターン
import { cache } from 'react';
export const getUser = cache(async (id: string) => {
return db.user.findUnique({ where: { id } });
});
export const preloadUser = (id: string) => {
void getUser(id); // fire-and-forget, deduped by cache()
};
リンクのプリロード戦略
ルール
- 静的/一般的なルートは積極的にプリロードする
- パーソナライズされた URL、クエリの多い URL、変動する URL ではプリフェッチを無効にする
- シェルをプリロードし、動的境界の背後にあるユーザー固有のデータは後回しにする
generateStaticParamsは、一般的なパスでのプリフェッチヒット率を高めます
import Link from 'next/link';
/** Static/common route: keep default prefetch. */
<Link href={`/docs/${slug}`}>Read next</Link>
/** Personalized or volatile route: disable speculative prefetch. */
<Link href={`/certificate/${userId}?name=${encodeURIComponent(name)}`} prefetch={false}>
View certificate
</Link>
プリロード判断チェックリスト
- ルートは静的で、頻繁に訪問されますか? → プリロードする
- ルートはパーソナライズされている、または変動しますか? → プリロードしない
- ユーザーデータは Suspense/動的アイランドの背後に遅延されていますか? → シェルのみをプリロードする
- ナビゲーションの改善を計測できていますか? → できていればプリフェッチを維持し、そうでなければ削除する
判断マトリクス
| シナリオ | パターン |
|---|---|
| 静的コンテンツ+パーソナライズされた操作 | エントリー(動的)+キャッシュされた静的レンダラー+スロット注入 |
| キャッシュ可能で決定的なサーバー処理 | 'use cache' 境界 |
| 純粋なクライアントインタラクティビティ | ローカルな 'use client' プロバイダーアイランド |
| ナビゲーションの高速化 | 対象を絞ったプリフェッチ+静的パラメーターの網羅 |
よくある失敗と対処法
-
ルート全体が予期せず動的になる
- 原因:リクエストに依存する読み取り(
cookies()、headers())が静的シェルに入り込んでいる - 対処法:読み取り処理をエントリーコンポーネントに移し、スロットの props を渡す
- 原因:リクエストに依存する読み取り(
-
クライアントのハイドレーションが重すぎる
- 原因:機能ローカルの状態管理に、ルートでグローバルプロバイダーがマウントされている
- 対処法:プロバイダーを機能/レイアウトのセグメントに移す
-
プリフェッチの浪費とネットワークのノイズ
- 原因:パーソナライズされたリンクやクエリの多いリンクをプリフェッチしている
- 対処法:変動する URL では
prefetch={false}を指定する
-
動的処理によって静的シェルがブロックされる
- 原因:動的コンポーネントが Suspense の境界なしでレンダリングされている
- 対処法:動的スロットを小さなフォールバックとともに Suspense でラップする
-
データフローの担当箇所が不明確
- 原因:静的ロジックと動的ロジックが 1 つのコンポーネントに混在している
- 対処法:厳密な props の契約を設け、エントリーと静的レンダラーを分離する
-
useSearchParamsによってページ全体が CSR にフォールバックする- 原因:検索パラメーターを読み取るコンポーネントの周囲に Suspense 境界がない
- 対処法:
useSearchParamsを使うコンポーネントは必ず Suspense でラップする
-
Date/Map/クラスの props によってクライアントコンポーネントが気付かないうちに壊れる
- 原因:RSC→クライアント境界を越えてシリアライズできない props が渡されている
- 対処法:サーバー側でシリアライズする(
.toISOString()、Object.fromEntries()、プレーンオブジェクト)
-
unstable_cacheがまだコードベースに残っている- 原因:v16 より前のキャッシュパターンが移行されていない
- 対処法:
'use cache'+cacheTag()+cacheLife()に置き換える — 手動のキー配列は不要
以前のバージョンからの移行
| 古い設定 | 置き換え先 |
|---|---|
experimental.ppr | cacheComponents: true |
dynamic = 'force-dynamic' | 削除する(デフォルトの動作) |
dynamic = 'force-static' | 'use cache'+cacheLife('max') |
revalidate = N | cacheLife({ revalidate: N }) |
unstable_cache() | 'use cache' ディレクティブ |
unstable_cache → 'use cache'
// Before
const getCachedUser = unstable_cache(
async (id) => getUser(id),
['my-app-user'],
{ tags: ['users'], revalidate: 60 }
)
// After
async function getCachedUser(id: string) {
'use cache'
cacheTag('users')
cacheLife({ revalidate: 60 })
return getUser(id)
}
主な違い:手動でのキャッシュキーは不要です(引数とクロージャーから自動生成)。タグは cacheTag()、再検証は cacheLife() を使います。
制限事項
- Edge runtime はサポートされていません — Node.js が必要です
- 静的エクスポートはサポートされていません — サーバーが必要です
- 非決定的な値(
Math.random()、Date.now())は、'use cache'内ではビルド時に一度だけ実行されます
キャッシュの外でリクエスト時の乱数を使う場合:
import { connection } from 'next/server'
async function DynamicContent() {
await connection() // defer to request time
const id = crypto.randomUUID()
return <div>{id}</div>
}
実装手順
- ルートごとに静的入力と動的入力を特定します
- ページを Entry とキャッシュ済み Static レンダラーに分割します
- パーソナライズされた部分を型付きスロットに変換します
- スロットのレンダリング箇所を Suspense で囲みます
- プロバイダーをクライアントアイランドにリファクタリングします
- ナビゲーションリンクにプリフェッチルールを適用します
- トラフィックの多い静的ルートに静的パラメーターを追加します
- 変更前後を計測します(TTFB、シェルの描画、ナビゲーション遅延、プリフェッチトラフィック)
PR の受け入れ基準
- 静的シェルがユーザー固有のデータを待たずにレンダリングされる
- キャッシュ境界にリクエストごとに変化する値の読み取りが含まれていない
- 動的 UI が明示的なスロット境界を通じてのみ表示される
- クライアントプロバイダーが、全体に適用する正当な理由がない限り、機能ごとにスコープされている
- パーソナライズされたリンクや変動するリンクでは、プリフェッチが明示的に無効化されている
- よく使われる静的ルートへのナビゲーションが事前読み込みされ、速度の向上を測定できる
-
useSearchParamsを使うコンポーネントが Suspense で囲まれている - シリアライズできない props が RSC→クライアント境界を越えていない
-
unstable_cacheが残っておらず、'use cache'ディレクティブに移行されている