株式会社ファストコーディングのフルスタックエンジニア MrFireです。
最近、仕事仲間に誘われて再開したギターの練習で、バンドのコピー曲に取り組んでいます。ギターパートを耳コピしていると、同じギターでもリズムとリードで完全に役割が分かれていることに改めて気づきます。リズムギターはコード進行を支えて全体の土台を作り、リードギターはその上でメロディやソロを自由に動く。どちらも「ギター」だけど、役割と責務がまったく違います。
最近のNext.jsも同じで、Server ComponentとClient Componentの「役割分担」が曲の出来を左右するんですよね。ギターの分離よりは難しくないはずなんですが。
この「役割分担」がうまくいかずに苦労した案件が、つい先日ありました。クライアントのコーポレートサイトをNext.js App Routerでリニューアルするプロジェクトで、チーム内から「どのコンポーネントに "use client" をつけるべきかわからない」という声が上がったのです。
この記事では、Next.js App RouterにおけるServer ComponentとClient Componentの境界をどう設計するかを、AI駆動開発のアプローチで整理した過程を紹介します。コンポーネント設計に迷っているフロントエンドチームや、App Routerへの移行を検討しているディレクターの方に向けて書いています。
「とりあえず全部 Client Component」で何が起きたか
プロジェクト初期、チームはPages Routerの経験はあるもののApp Routerは初めてでした。Server Componentという概念に馴染みがなく、「動かないよりは動くほうがいい」という判断で、ほぼすべてのコンポーネントに "use client" を付けていました。
結果として、ビルドしたバンドルサイズは約420KB。コーポレートサイトとしてはかなり大きい数字です。Lighthouseで計測すると、TTI(Time to Interactive)はモバイルで4.2秒。クライアントから「前のサイトより表示が遅くなった気がする」という指摘を受けました。
問題は明確でした。Server Componentで処理すればJavaScriptのバンドルに含まれない部分まで、すべてクライアント側に送ってしまっていたのです。
AI に「この コンポーネント、Server と Client どっちにすべき?」と聞く
ここからAI駆動開発のアプローチに切り替えました。私たちのチームでは、設計判断に迷ったときにClaudeに相談するフローを取り入れています。
まず、プロジェクトの主要なコンポーネントのコードをClaudeに渡して、「このコンポーネントはServer ComponentとClient Componentのどちらにすべきか、理由とともに判定してほしい」と依頼しました。
AIが返してきた判定基準は、シンプルに3つでした。
useState、useEffect、useRefなどのReact Hooksを使っているか → Client ComponentonClick、onChangeなどのイベントハンドラを使っているか → Client Component- 上記のどちらにも該当しない → Server Component
この基準自体はNext.jsの公式ドキュメントにも書かれている内容です。ただ、AIが良かったのは、実際のコードに対して1つずつ「これはServer、これはClient」と具体的に仕分けてくれた点です。抽象的なルールを読むだけでは判断に迷う場面でも、具体的なコードに対する回答があると腹落ちしやすい。
Bad Case:ページ全体を Client Component にする
まず、改善前のコードを見てみます。チームが最初に書いた商品詳細ページです。
"use client";
import { useEffect, useState } from "react";
type Product = {
id: string;
name: string;
price: number;
description: string;
images: string[];
};
export default function ProductPage({ params }: { params: { id: string } }) {
const [product, setProduct] = useState<Product | null>(null);
const [selectedImage, setSelectedImage] = useState(0);
const [isAddingToCart, setIsAddingToCart] = useState(false);
useEffect(() => {
fetch(`/api/products/${params.id}`)
.then((res) => res.json())
.then((data) => setProduct(data));
}, [params.id]);
if (!product) return <div>読み込み中...</div>;
const handleAddToCart = async () => {
setIsAddingToCart(true);
await fetch("/api/cart", {
method: "POST",
body: JSON.stringify({ productId: product.id }),
});
setIsAddingToCart(false);
alert("カートに追加しました");
};
return (
<div>
<h1>{product.name}</h1>
<div>
<img src={product.images[selectedImage]} alt={product.name} />
<div>
{product.images.map((img, i) => (
<button key={i} onClick={() => setSelectedImage(i)}>
<img src={img} alt={`${product.name} ${i + 1}`} />
</button>
))}
</div>
</div>
<p>{product.description}</p>
<p>¥{product.price.toLocaleString()}</p>
<button onClick={handleAddToCart} disabled={isAddingToCart}>
{isAddingToCart ? "追加中..." : "カートに追加"}
</button>
</div>
);
}このコードの問題点を整理すると、以下のようになります。
- ページ全体が
"use client"なので、すべてのJavaScriptがブラウザに送られる - データ取得を
useEffectで行っているため、初期表示が「読み込み中…」になる - 商品説明や価格表示などインタラクションのない部分まで、Client Componentのバンドルに含まれる
このコードをAIに見せて「Server ComponentとClient Componentに分割してほしい」と依頼しました。
AIが出した初版の分割案
Claudeに依頼した結果、AIは以下のような分割案を提示しました。
ProductPage(Server Component):データ取得と全体のレイアウトProductImageGallery(Client Component):画像の選択UIにuseStateが必要AddToCartButton(Client Component):クリックイベントと状態管理が必要ProductDescription(Server Component):テキストを表示するだけ
この判定は的確でした。AIの強みは「このコンポーネントのどの行がClient Componentを必要としているか」を機械的に判定できる点です。人間が感覚で「なんとなく全部Clientかな」と判断しがちな部分を、ロジカルに切り分けてくれます。
Good Case:Server Component をベースにインタラクション部分だけを分離する
AIの提案をベースに、実際にリファクタリングしたコードがこちらです。
まず、ページのエントリーポイントをServer Componentにします。
// app/products/[id]/page.tsx(Server Component)
import { ProductImageGallery } from "./ProductImageGallery";
import { AddToCartButton } from "./AddToCartButton";
type Product = {
id: string;
name: string;
price: number;
description: string;
images: string[];
};
async function getProduct(id: string): Promise<Product> {
const res = await fetch(`https://api.example.com/products/${id}`, {
next: { revalidate: 60 },
});
return res.json();
}
export default async function ProductPage({
params,
}: {
params: { id: string };
}) {
const product = await getProduct(params.id);
return (
<div>
<h1>{product.name}</h1>
<ProductImageGallery images={product.images} name={product.name} />
<p>{product.description}</p>
<p>¥{product.price.toLocaleString()}</p>
<AddToCartButton productId={product.id} />
</div>
);
}ポイントは、async function でデータ取得を直接行っている点です。Server Componentなので、useEffect でクライアント側から再フェッチする必要がありません。HTMLが生成された状態でブラウザに届くため、「読み込み中…」の表示も不要になります。
次に、画像ギャラリーのClient Componentです。
// app/products/[id]/ProductImageGallery.tsx
"use client";
import { useState } from "react";
type Props = {
images: string[];
name: string;
};
export function ProductImageGallery({ images, name }: Props) {
const [selectedIndex, setSelectedIndex] = useState(0);
return (
<div>
<img src={images[selectedIndex]} alt={name} />
<div>
{images.map((img, i) => (
<button
key={i}
onClick={() => setSelectedIndex(i)}
style={{
border: i === selectedIndex ? "2px solid #333" : "1px solid #ccc",
}}
>
<img src={img} alt={`${name} ${i + 1}`} width={60} height={60} />
</button>
))}
</div>
</div>
);
}そして、カートに追加するボタンのClient Componentです。
// app/products/[id]/AddToCartButton.tsx
"use client";
import { useState } from "react";
type Props = {
productId: string;
};
export function AddToCartButton({ productId }: Props) {
const [isAdding, setIsAdding] = useState(false);
const handleClick = async () => {
setIsAdding(true);
await fetch("/api/cart", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ productId }),
});
setIsAdding(false);
alert("カートに追加しました");
};
return (
<button onClick={handleClick} disabled={isAdding}>
{isAdding ? "追加中..." : "カートに追加"}
</button>
);
}この分割で変わったことを整理すると、以下の3点です。
- データ取得がサーバー側で完結し、初期表示がHTMLとして届く
"use client"がついているのはProductImageGalleryとAddToCartButtonの2コンポーネントだけ- 商品名・説明・価格はServer Componentなので、JavaScriptバンドルに含まれない
Composition パターン:Server Component を Client Component の children に渡す
AIとのやりとりで特に参考になったのが、Server ComponentをClient Componentの children として渡すパターンです。
たとえば、レイアウトにインタラクティブなサイドバーがある場合、以下のように構成できます。
// app/layout.tsx(Server Component)
import { Sidebar } from "./Sidebar";
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<div style={{ display: "flex" }}>
<Sidebar>
{/* この部分はServer Componentのまま */}
<nav>
<a href="/products">商品一覧</a>
<a href="/about">会社概要</a>
<a href="/contact">お問い合わせ</a>
</nav>
</Sidebar>
<main>{children}</main>
</div>
);
}// app/Sidebar.tsx(Client Component)
"use client";
import { useState } from "react";
type Props = {
children: React.ReactNode;
};
export function Sidebar({ children }: Props) {
const [isOpen, setIsOpen] = useState(true);
return (
<aside style={{ width: isOpen ? "240px" : "60px", transition: "width 0.3s" }}>
<button onClick={() => setIsOpen(!isOpen)}>
{isOpen ? "閉じる" : "開く"}
</button>
{isOpen && children}
</aside>
);
}Sidebar 自体はClient Componentですが、children として渡されるナビゲーション部分はServer Componentのままレンダリングされます。このパターンを使えば、「開閉の状態管理」というインタラクション部分だけをClient Componentに閉じ込め、中身のコンテンツはサーバー側で処理できます。
AIにこのパターンについて聞いたところ、「children を ReactNode 型で受け取るClient Componentは、その子要素のレンダリング方式を変えない」という説明が返ってきました。正確な回答です。
リファクタリング後の計測結果
改善後にビルドしてバンドルサイズを計測しました。
| 指標 | Before | After | 改善幅 |
|---|---|---|---|
| バンドルサイズ | 420KB | 180KB | -57% |
| TTI(モバイル) | 4.2秒 | 2.5秒 | -40% |
| LCP | 3.1秒 | 1.8秒 | -42% |
| CLS | 0.12 | 0.03 | -75% |
バンドルサイズが57%削減されたことで、TTIが40%改善しました。特にモバイル環境での体感速度の差は大きく、クライアントからも「表示が速くなった」と好評でした。
CLSの改善は、useEffect でのデータ取得をやめたことで初期表示時のレイアウトシフトがなくなったためです。Server Componentでデータを取得してHTMLを返すので、ブラウザに届いた時点で完成した状態になっています。
AI が苦手だった部分:UX のトレードオフ判断
AIはコンポーネントの機械的な仕分けには非常に優秀でした。ただし、UX上のトレードオフが絡む判断については、人間の判断が必要な場面がいくつかありました。
1つ目は、ローディング状態の設計です。Server Componentではデータ取得がサーバー側で完結するため、ブラウザには完成したHTMLが届きます。これは速度面ではメリットですが、「データ取得に時間がかかる場合にユーザーに何を見せるか」という判断はAIにはできません。Suspenseのfallbackとして何を表示すべきかは、ビジネス要件とUXの文脈を理解しないと決められない領域です。
2つ目は、プログレッシブエンハンスメントの判断です。たとえば検索フォームを「Server Componentで通常のフォーム送信にするか、Client Componentでリアルタイム検索にするか」という選択は、パフォーマンスだけでは決められません。ユーザーの利用パターン、検索頻度、データ量など、プロダクトの文脈に依存します。
AIに「検索フォームはどちらにすべきか」と聞くと、「インタラクティブにするならClient、シンプルにするならServer」という一般論は返ってきます。ただ、「このプロダクトではどちらが適切か」という判断は、結局人間がする必要がありました。
実際に私たちが出した結論は「初期リリースではServer Componentでシンプルなフォーム送信にして、ユーザーのフィードバックを見てからClient Componentでリアルタイム検索に移行する」というものでした。段階的なアプローチを取れるのもServer Componentベースの設計の利点です。
Server / Client の境界を決めるチェックリスト
AI との壁打ちを経て、チーム内で共有したチェックリストを紹介します。
useState/useReducer/useEffect/useRefを使っているか → ClientonClick/onChange/onSubmitなどのイベントハンドラがあるか → Clientwindow/document/localStorageなどブラウザAPIを直接使っているか → Client- 上記のいずれにも該当しないか → Server(デフォルト)
このチェックリストに従って判定し、迷ったらまずServer Componentとして実装してみる。"use client" が必要になったタイミングで、インタラクション部分だけを切り出す。このフローが最もシンプルで確実でした。
ポイントは「デフォルトをServer Componentにする」という考え方です。Pages Routerでは全てがClient Componentだったので、つい "use client" をつけてしまいがちですが、App Routerでは逆の発想が必要です。
まとめ
今回は、Next.js App RouterにおけるServer ComponentとClient Componentの境界設計を、AI駆動開発のアプローチで最適化した事例を紹介しました。
「全部Client Componentにしておけばとりあえず動く」は間違いではありませんが、パフォーマンス面で大きな代償を払うことになります。Server Componentをデフォルトにする発想への切り替えが、App Router移行の第一歩です。
押さえておきたいポイントは以下の3つです。
"use client"はページ全体ではなく、Hooksやイベントハンドラを使う最小単位のコンポーネントに付ける- Server Componentを
childrenとして Client Componentに渡すCompositionパターンを活用する - AIはコンポーネントの機械的な仕分けに強いが、UXのトレードオフ判断は人間が行う
実際のプロジェクトでは、この方法でバンドルサイズを57%削減し、TTIを40%改善できました。特にモバイルユーザーが多いサイトでは、この差がコンバージョンに直結します。
株式会社ファストコーディングでは、React/Next.jsのフロントエンド実装やAI駆動開発のご相談を承っています。App Routerへの移行やパフォーマンス改善でお困りの際は、お問い合わせフォームからお気軽にご連絡ください。

