対象: Next.js 16 系、React 19、App Router 目的: 新規開発・既存プロジェクトのレビュー時に参照するチェックリスト兼ガイド
目次
- プロジェクト構成
- Server Components と Client Components
- データ取得
- キャッシュと再検証(Cache Components)
- ミューテーション(Server Actions)
- ルーティングと UI 状態
- Proxy(旧 Middleware)
- パフォーマンス最適化
- SEO とメタデータ
- セキュリティ
- 型安全性とコード品質
- テスト
- デプロイと運用
- アンチパターン集
- レビューチェックリスト
1. プロジェクト構成
推奨ディレクトリ構成
src/
├── app/ # ルーティング専用(薄く保つ)
│ ├── (marketing)/ # Route Group: URL に影響しないグルーピング
│ │ ├── page.tsx
│ │ └── layout.tsx
│ ├── (app)/
│ │ ├── dashboard/
│ │ │ ├── page.tsx
│ │ │ ├── loading.tsx
│ │ │ ├── error.tsx
│ │ │ └── _components/ # このルート専用のコンポーネント(_ でルート対象外)
│ │ └── layout.tsx
│ ├── api/ # Route Handlers(外部公開APIが必要な場合のみ)
│ ├── layout.tsx # ルートレイアウト
│ ├── not-found.tsx
│ └── global-error.tsx
├── components/ # 共有 UI コンポーネント
│ └── ui/
├── lib/ # ユーティリティ、外部クライアント
├── server/ # サーバー専用ロジック(DB、認証、データアクセス層)
│ ├── db.ts
│ ├── auth.ts
│ └── data/ # データアクセス関数(DAL)
├── actions/ # Server Actions
└── proxy.ts # 旧 middleware.ts
原則
app/はルーティングに専念させる。 ビジネスロジックはserver/やlib/に置き、page.tsxは「データを取得して組み立てる」だけにする。- コロケーション: 特定ルート専用のコンポーネントは
_components/のようなプライベートフォルダでルートの近くに置く。 - Route Groups
(name)でレイアウトを分ける(例: マーケティングページとログイン後のアプリでレイアウトを分離)。 src/ディレクトリを使うと設定ファイルとアプリコードが分離でき見通しが良い。- パスエイリアス(
@/*)をtsconfig.jsonで設定し、深い相対パスを避ける。
2. Server Components と Client Components
デフォルトは Server Component
App Router ではすべてのコンポーネントがデフォルトで Server Component。"use client" は必要な場所にだけ、できるだけ末端(葉)に付ける。
| Server Component を使う | Client Component を使う |
|---|---|
| データ取得 | useState / useEffect などのフック |
| DB・シークレットへのアクセス | イベントハンドラ(onClick など) |
| 大きな依存ライブラリの使用(JS をクライアントに送らない) | ブラウザ API(window, localStorage) |
| 静的なマークアップ | インタラクティブな UI |
ベストプラクティス
// ❌ ページ全体を Client Component にしている
"use client";
export default function Page() { /* ... */ }
// ✅ インタラクティブな部分だけを切り出す
// app/products/page.tsx(Server Component)
import { LikeButton } from "./_components/like-button";
export default async function Page() {
const products = await getProducts();
return products.map((p) => (
<article key={p.id}>
<h2>{p.name}</h2>
<LikeButton productId={p.id} /> {/* ここだけ "use client" */}
</article>
));
}- Server Component を Client Component の
childrenとして渡すことで、Client の内側にも Server のツリーを保てる。 - Props はシリアライズ可能な値のみ。関数・クラスインスタンス・Date は渡す前に変換する(Server Actions は例外的に渡せる)。
- サーバー専用モジュールには
import "server-only"を書き、誤ってクライアントにバンドルされるのを防ぐ。 - Context Provider は Client Component として作り、ルートレイアウトで
childrenを包む。Provider はできるだけ深い位置に置く。
3. データ取得
Server Component で直接取得する
// app/posts/[id]/page.tsx
export default async function Page({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params; // Next.js 15 以降 params は Promise
const post = await getPost(id);
if (!post) notFound();
return <PostView post={post} />;
}原則
-
自分のアプリの Route Handler を Server Component から
fetchしない。 余計なネットワーク往復になる。データアクセス関数を直接呼ぶ。 -
ウォーターフォールを避ける: 依存関係のないデータは
Promise.allで並列取得する。const [user, posts] = await Promise.all([getUser(id), getPosts(id)]); -
Suspense でストリーミング: 遅いデータは別コンポーネントに分け
<Suspense>で囲み、ページ全体のブロックを避ける。 -
リクエスト内の重複排除: 同じデータを複数コンポーネントで使う場合は React の
cache()でラップする。import { cache } from "react"; export const getUser = cache(async (id: string) => db.user.findUnique({ where: { id } })); -
データアクセス層(DAL)を作る: 認可チェックと DTO 変換(必要なフィールドだけ返す)を DAL に集約する。
// server/data/posts.ts import "server-only"; import { cache } from "react"; import { getSession } from "@/server/auth"; import { db } from "@/server/db"; export const getPost = cache(async (id: string) => { const session = await getSession(); if (!session) return null; // 認証 const post = await db.post.findUnique({ where: { id } }); if (!post || post.authorId !== session.userId) return null; // 認可 // DTO: クライアントに渡してよいフィールドだけ返す return { id: post.id, title: post.title, body: post.body, createdAt: post.createdAt.toISOString() }; });server-onlyで誤インポートを防ぎ、cache()でリクエスト内の重複呼び出しをまとめる。- ページ・レイアウト・
generateMetadata・Server Action はこの関数を経由してデータに触れる。DB を直接呼ばない。 - レイアウトだけで認可チェックをしない(レイアウトはナビゲーション時に再実行されないことがある)。
-
Dynamic API は非同期:
cookies(),headers(),draftMode(),params,searchParamsはすべてawaitが必要。 -
クライアント側でのデータ取得が必要な場合(リアルタイム更新、無限スクロールなど)は SWR / TanStack Query を使う。
4. キャッシュと再検証(Cache Components)
Next.js 16 では Cache Components によりキャッシュが「明示的なオプトイン」になった。デフォルトは動的で、キャッシュしたい箇所に "use cache" を書く。
有効化
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
cacheComponents: true,
};
export default nextConfig;使い方
import { cacheLife, cacheTag } from "next/cache";
export async function getProducts() {
"use cache";
cacheLife("hours"); // 事前定義プロファイル: seconds/minutes/hours/days/weeks/max
cacheTag("products"); // タグで後から無効化できる
return db.product.findMany();
}"use cache"はファイル・コンポーネント・関数単位で付けられる。- キャッシュされた部分は静的シェルとして事前レンダリングされ、動的な部分は
<Suspense>内でストリーミングされる(Partial Prerendering)。 - キャッシュ関数の中で
cookies()/headers()を直接呼ばない。 必要な値は外で読み取って引数として渡す(引数はキャッシュキーの一部になる)。
再検証
| API | 用途 |
|---|---|
updateTag(tag) | Server Action 内で使用。書き込み直後にユーザーが変更を即座に見られる(read-your-writes) |
revalidateTag(tag, profile) | stale-while-revalidate で再検証(Next.js 16 では第2引数に cacheLife プロファイルを指定) |
revalidatePath(path) | パス単位で無効化 |
refresh() | Server Action からキャッシュされていないデータのみを更新 |
原則
- ユーザー固有データはキャッシュしない(または
"use cache: private"などの適切なスコープを使う)。共有キャッシュへの個人情報混入は重大インシデントになる。 - タグ設計を最初に決める(例:
products,product:${id},user:${id}:orders)。 - キャッシュは「速くするため」ではなく「何をどれだけ古くしてよいか」の仕様として決める。
5. ミューテーション(Server Actions)
// actions/post.ts
"use server";
import { z } from "zod";
import { updateTag } from "next/cache";
import { redirect } from "next/navigation";
import { getSession } from "@/server/auth";
const schema = z.object({
title: z.string().min(1).max(100),
body: z.string().max(10_000),
});
export async function createPost(prevState: unknown, formData: FormData) {
// 1. 認証・認可(Server Action は公開エンドポイントと同じ扱い)
const session = await getSession();
if (!session) return { error: "ログインが必要です" };
// 2. 入力検証
const parsed = schema.safeParse(Object.fromEntries(formData));
if (!parsed.success) return { error: parsed.error.flatten().fieldErrors };
// 3. 処理
const post = await db.post.create({ data: { ...parsed.data, authorId: session.userId } });
// 4. キャッシュ更新とリダイレクト
updateTag("posts");
redirect(`/posts/${post.id}`);
}// Client 側
"use client";
import { useActionState } from "react";
import { createPost } from "@/actions/post";
export function PostForm() {
const [state, action, pending] = useActionState(createPost, null);
return (
<form action={action}>
<input name="title" />
<textarea name="body" />
<button disabled={pending}>{pending ? "送信中…" : "投稿"}</button>
{state?.error && <p role="alert">{String(state.error)}</p>}
</form>
);
}原則
-
Server Action は誰でも直接呼び出せる公開 API として扱う。 毎回、認証・認可・入力検証を行う。
-
入力検証には Zod / Valibot などのスキーマライブラリを使う。
-
期待されるエラー(バリデーション失敗など)は 戻り値で返す。想定外のエラーは throw して
error.tsxに任せる。 -
useActionState(状態)・useFormStatus(送信中表示)・useOptimistic(楽観的更新)を活用する。 -
<form action={...}>を使うと JS 読み込み前でも動作する(プログレッシブエンハンスメント)。 -
データ取得には Server Action を使わない(POST で直列実行されるため)。取得は Server Component で行う。
-
redirect()をtry/catchの中で呼ばない。redirect()/notFound()は内部で例外を投げて動作するため、catchに捕まると遷移しなくなる。処理をtry/catchで囲み、redirect()はその外で呼ぶ。let post; try { post = await db.post.create({ data }); } catch { return { error: "保存に失敗しました" }; } redirect(`/posts/${post.id}`); // try/catch の外
Route Handlers との使い分け
| 用途 | 使うもの |
|---|---|
| アプリ内のフォーム送信・データ更新 | Server Action |
| Webhook(Stripe、GitHub など) | Route Handler |
| 外部・モバイルアプリ向けの API | Route Handler |
| ファイル配信、RSS、ストリーミングレスポンス | Route Handler |
- Webhook は 署名を必ず検証 する(未検証のリクエストを処理しない)。
- 外部公開の Route Handler には レート制限 を入れる(Upstash Ratelimit、API Gateway など)。
- Route Handler も Server Action と同じく、毎回認証・認可・入力検証を行う。
6. ルーティングと UI 状態
特殊ファイルを活用する
| ファイル | 役割 |
|---|---|
loading.tsx | ルート単位のローディング UI(自動で Suspense 境界になる) |
error.tsx | ルート単位のエラー境界("use client" 必須) |
not-found.tsx | notFound() 呼び出し時の UI |
global-error.tsx | ルートレイアウトのエラーを捕捉 |
default.tsx | Parallel Routes のフォールバック(Next.js 16 では必須) |
forbidden.tsx / unauthorized.tsx | forbidden()(403)/ unauthorized()(401)呼び出し時の UI(experimental.authInterrupts で有効化) |
- 権限がない場合は
notFound()で隠すかforbidden()で 403 を返すかを方針として決めておく(リソースの存在自体を隠したい場合はnotFound())。
ナビゲーション
- 内部リンクは必ず
<Link>を使う(プリフェッチとクライアント遷移が効く)。 - プログラム遷移は
useRouter()(Client)、サーバー側ではredirect()/permanentRedirect()。 - URL を状態として使う: フィルタ・ソート・ページ番号・タブなどは
searchParamsに持たせると、共有・ブックマーク・SSR が容易になる(nuqsなどのライブラリも有用)。 - モーダルは Parallel Routes + Intercepting Routes で実装すると URL 共有とリロードに強くなる。
7. Proxy(旧 Middleware)
Next.js 16 で middleware.ts は proxy.ts に名称変更され、Node.js ランタイムで動作する。
// src/proxy.ts
import { NextResponse, type NextRequest } from "next/server";
export function proxy(request: NextRequest) {
const hasSession = request.cookies.has("session");
if (!hasSession && request.nextUrl.pathname.startsWith("/dashboard")) {
return NextResponse.redirect(new URL("/login", request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};原則
- Proxy はリダイレクト・リライト・ヘッダー付与・楽観的な認証チェックなどの軽い処理に限定する。
- Proxy だけで認可を完結させない。 実際の認可はデータアクセス層・Server Action でも必ず行う(多層防御)。
matcherで静的アセットを除外し、不要な実行を避ける。- 重い DB アクセスを Proxy に入れない(全リクエストのレイテンシに乗る)。
8. パフォーマンス最適化
画像
import Image from "next/image";
<Image src="/hero.jpg" alt="説明文" width={1200} height={600} priority sizes="100vw" />next/imageを使い、width/height(またはfill+sizes)を指定して CLS を防ぐ。- ファーストビューの LCP 画像には
priorityを付ける。 - 外部画像は
images.remotePatternsで許可ドメインを限定する。
フォント
import { Noto_Sans_JP } from "next/font/google";
const notoSansJP = Noto_Sans_JP({ subsets: ["latin"], weight: ["400", "700"], display: "swap" });next/fontでセルフホスト化し、レイアウトシフトと外部リクエストを削減する。
JavaScript 削減
"use client"の範囲を最小化する(最大の効果がある)。- 重いクライアントコンポーネントは
next/dynamicで遅延読み込み。 - サードパーティスクリプトは
next/script(strategy="lazyOnload"など)や@next/third-partiesを使う。 @next/bundle-analyzerで定期的にバンドルサイズを確認する。
その他
- Turbopack は Next.js 16 で開発・ビルドともにデフォルト。特別な理由がなければそのまま使う。
- React Compiler を有効化すると
useMemo/useCallbackの手動最適化をほぼ不要にできる(reactCompiler: true)。 - Core Web Vitals(LCP / INP / CLS)を
useReportWebVitalsや Vercel Speed Insights で計測する。
9. SEO とメタデータ
// 静的
export const metadata: Metadata = {
title: { default: "サイト名", template: "%s | サイト名" },
description: "サイトの説明",
};
// 動的
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { id } = await params;
const post = await getPost(id); // cache() でページ本体と重複排除される
return {
title: post.title,
openGraph: { images: [post.ogImage] },
};
}app/sitemap.tsとapp/robots.tsでサイトマップ・robots.txt を生成する。- OG 画像は
opengraph-image.tsx(ImageResponse)で動的生成できる。 - 構造化データ(JSON-LD)は
<script type="application/ld+json">で埋め込む。 metadataBaseをルートレイアウトで設定し、相対 URL を正しく解決させる。
10. セキュリティ
- 常に最新パッチを適用する。 Next.js は定期的なセキュリティリリースを行っている。Dependabot / Renovate で自動化する。
- 例: CVE-2025-66478 は React Server Components のプロトコルにおけるリモートコード実行の脆弱性(CVSS 10.0)で、Next.js 16.0.4 で修正された。App Router を使っているだけで影響を受けうるため、セキュリティ告知は必ず追う。
- 認証は自前で実装しない。 Auth.js、Better Auth、Clerk などの実績あるライブラリ・サービスを使う。セッション Cookie は
HttpOnly・Secure・SameSite=Lax以上にする。 - 環境変数:
NEXT_PUBLIC_付きはクライアントに露出する。シークレットには絶対に付けない。- 起動時に Zod などで環境変数を検証する(
@t3-oss/env-nextjsなど)。
- Server Actions / Route Handlers は毎回認証・認可・入力検証を行う。
- Taint API(
experimental_taintObjectReference/taintUniqueValue)で機密値のクライアント流出を防ぐことも検討する。 - データ最小化: DB のレコードをそのまま Client Component に渡さず、必要なフィールドだけの DTO にする。
- セキュリティヘッダー(CSP、
X-Frame-Options、Referrer-Policyなど)をnext.config.tsのheaders()または Proxy で設定する。 dangerouslySetInnerHTMLを使う場合は必ずサニタイズ(DOMPurify など)。- 外部画像・リダイレクト先は許可リストで制限する(オープンリダイレクト対策)。
11. 型安全性とコード品質
- TypeScript strict モードを有効にする。
- Typed Routes(
typedRoutes: true)で<Link href>の型チェックを行う。 next typegen/ 自動生成されるPageProps<"/posts/[id]">などのヘルパー型でparamsを型安全にする。next lintは Next.js 16 で削除されたため、ESLint(eslint-config-next)または Biome を直接実行する。- Prettier(または Biome)でフォーマットを統一し、CI で
tsc --noEmitと Lint を実行する。
12. テスト
| 種類 | ツール例 | 対象 |
|---|---|---|
| ユニット | Vitest / Jest + Testing Library | ユーティリティ、Client Component、同期 Server Component |
| E2E | Playwright | async Server Component、ページ遷移、フォーム送信 |
| ビジュアル | Storybook + Chromatic | UI コンポーネント |
- async Server Component はユニットテストツールの対応が限定的なため、E2E でカバーするのが現実的。
- データアクセス層・Server Action のロジックは関数として切り出し、ユニットテストしやすくする。
13. デプロイと運用
-
output: "standalone"で Docker イメージを軽量化できる(セルフホスト時)。 -
セルフホストで複数インスタンスを動かす場合は、共有キャッシュハンドラ(Redis など)を設定する。
-
Vercel 以外のプラットフォーム(OpenNext、Cloudflare、AWS Amplify など)は Build Adapters API 経由で
proxy.tsや Cache Components を含むフル機能に対応している。採用前に利用するアダプターの対応状況を確認する。 -
メジャーバージョンのアップグレードは codemod を使う。
npx @next/codemod@canary upgrade latest -
instrumentation.tsで OpenTelemetry・Sentry などの監視を初期化する。 -
エラー監視(Sentry など)とログ基盤を本番前に整える。
-
プレビュー環境(PR ごとのデプロイ)でレビューする運用にする。
-
Node.js は 20.9 以上(LTS 推奨)。
14. アンチパターン集
| アンチパターン | 問題 | 代替策 |
|---|---|---|
ルートレイアウトやページ全体に "use client" | JS が肥大化、Server Components の利点を失う | インタラクティブ部分だけ切り出す |
Server Component から自アプリの /api/* を fetch | 余計な往復、ビルド時に失敗することも | DAL の関数を直接呼ぶ |
useEffect で初期データを取得 | ウォーターフォール、SEO 不利、ちらつき | Server Component で取得 |
| 認可を Proxy だけで実施 | バイパスされうる | DAL・Server Action でも検証 |
| Server Action で入力検証なし | 任意データの書き込み | Zod などで検証 |
| ユーザー固有データを共有キャッシュ | 他ユーザーへの情報漏洩 | キャッシュしない / private スコープ |
await を順番に並べる | 不要なウォーターフォール | Promise.all / Suspense |
<a> で内部リンク | フルリロード、プリフェッチなし | <Link> |
<img> 直書き | 最適化なし、CLS | next/image |
シークレットに NEXT_PUBLIC_ | クライアントに露出 | サーバー専用環境変数 |
redirect() を try/catch の中で呼ぶ | 例外が捕まり遷移しない | try/catch の外で呼ぶ |
| Webhook の署名を検証しない | なりすましリクエストを処理してしまう | 署名検証してから処理 |
| 認可をレイアウトだけで実施 | ナビゲーション時に再実行されずすり抜ける | DAL で毎回チェック |
15. レビューチェックリスト
アーキテクチャ
-
"use client"は必要最小限の末端コンポーネントだけに付いている - サーバー専用モジュールに
import "server-only"がある - ビジネスロジックが
app/から分離されている
データ・キャッシュ
- 独立したデータ取得が並列化されている
- 遅い部分が
<Suspense>で分離されている - キャッシュ対象・期間・タグが意図通りに設計されている
- ユーザー固有データが共有キャッシュに入っていない
セキュリティ
- すべての Server Action / Route Handler で認証・認可・入力検証をしている
- データアクセスが DAL を経由している(ページやレイアウトから DB を直接呼んでいない)
- Webhook の署名検証と、外部公開 API のレート制限がある
- クライアントに渡すデータが DTO で最小化されている
- シークレットに
NEXT_PUBLIC_が付いていない - Next.js / React が最新のパッチバージョン
UX・パフォーマンス
-
loading.tsx/error.tsx/not-found.tsxが適切に配置されている - 画像は
next/image、フォントはnext/font - LCP 画像に
priorityが付いている - メタデータ(title / description / OG)が設定されている
品質
- TypeScript strict、型エラーなし
- Lint / フォーマットが CI で実行されている
- 主要フローに E2E テストがある