Next.jsにおけるServer ComponentとClient Component
1. 概要
Next.js App Routerはデフォルトでcomponentをサーバー側でrenderします。これは従来のPages Routerの「すべてクライアント側」というモデルとは大きく異なります。この記事は、Server ComponentとClient Componentの違い、いつ"use client"を使う必要があるのか、そして2つのcomponent typeをレンダリングツリー内で組み合わせてエラーを回避する方法を理解するのに役立ちます。最後には、componentをサーバー側に置くべきかクライアント側に置くべきかを自分で判断でき、その境界を越えてデータを正しく渡す方法を知ることができます。
2. Server Componentとは
Server Componentは、App Router(app/フォルダ)におけるデフォルトのcomponent typeです。サーバー側で完全に実行され、それ自体のJavaScriptをブラウザに送信しません。
// app/products/page.tsx
async function getProducts() {
const res = await fetch("https://api.shop.local/products", { cache: "no-store" });
return res.json();
}
export default async function ProductsPage() {
const products = await getProducts();
return (
<ul>
{products.map((p: { id: number; name: string }) => (
<li key={p.id}>{p.name}</li>
))}
</ul>
);
}
注意すべき点がいくつかあります:
ProductsPageはasync functionです。Server Componentは直接awaitすることができ、データをfetchするためにuseEffectやuseStateは必要ありません。fetchコマンドはサーバー側で実行され、APIキーや機密ロジックはブラウザに公開されることはありません。- ファイルの先頭に
"use client"行がないため、Next.jsはこれがServer Componentであることを自動的に認識します。
2.1. Server Componentを優先すべき理由
Server Componentはクライアント側に送信されるJavaScript bundleを削減するのに役立ちます。component自体(およびそのimport、クライアント化されていない場合)がブラウザ側のbundleに含まれないからです。また、APIルートを通じて公開することなく、database、ファイルシステム、または秘密の環境変数に直接アクセスできます。
Request → Server render (fetch, DB query) → HTML + RSC payload → Browser
ブラウザはレンダリング済みのHTMLとcomponent treeを記述するpayload(RSC payload)のみを受け取り、Server ComponentのJavaScriptソースコードは受け取りません。
3. Client Componentとは
Client Componentはブラウザで実行する必要があるcomponentです。state、effect、event handler、またはlocalStorageやwindowなどのWeb APIを使用する場合に使います。ファイルの先頭の"use client"directiveで宣言します。
// app/components/LikeButton.tsx
"use client";
import { useState } from "react";
export default function LikeButton() {
const [liked, setLiked] = useState(false);
return (
<button onClick={() => setLiked(!liked)}>
{liked ? "❤️ 気に入った" : "🤍 気に入る"}
</button>
);
}
説明:
"use client"はファイルの最初の行(importの前)である必要があり、境界をマークします。このファイルからimportされるすべてはクライアント側用にbundleされます。useStateはClient Component内でのみ機能します。re-renderを管理するためにブラウザで実行されるJavaScriptが必要だからです。onClickはevent handlerです。Client Componentが必要な確実な兆候です。
注意:
"use client"は、componentがのみクライアント側で実行されることを意味しません。Next.jsは引き続きサーバー側で最初にrender(SSR)して高速HTMLを取得し、その後ブラウザで「hydrate」してcomponentをインタラクティブにします。
4. Client Componentをいつ必ず使う必要があるか
"use client"を追加する必要のあるケースがいくつかあります。そうしないと、buildまたはruntimeエラーが発生します:
Error: useState can not be used in Server Components.
これらのケースには以下が含まれます:
- React hookを使用:
useState、useEffect、useReducer、useContext… - Event handlerをアタッチ:
onClick、onChange、onSubmit… - ブラウザにのみ存在するWeb APIを使用:
window、document、localStorage。 - 上記を含むcustom hookを使用する。
- 上記に依存するクライアント専用ライブラリを使用する(例えば
windowに依存するcharting library)。
逆に、componentが単にpropsを受け取りJSXを純粋にrenderするだけで、hookなし、eventなし—他のClient Componentとネストされた深い場所にあっても、Server Componentのままにしておきます。
5. ServerとClient Componentを組み合わせる
このモデルの素晴らしい点は、直接importするのではなく、children(composition)を通じてServer ComponentをClient Componentに**ネストできることです。
// app/components/Modal.tsx
"use client";
import { useState } from "react";
export default function Modal({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(false);
return (
<>
<button onClick={() => setOpen(true)}>詳細を開く</button>
{open && <div className="modal">{children}</div>}
</>
);
}
// app/orders/[id]/page.tsx
import Modal from "@/app/components/Modal";
async function getOrder(id: string) {
const res = await fetch(`https://api.shop.local/orders/${id}`);
return res.json();
}
export default async function OrderPage({ params }: { params: { id: string } }) {
const order = await getOrder(params.id);
return (
<Modal>
{/* OrderDetailは引き続きServer Componentで、childrenとして「渡されている」だけです */}
<p>注文番号: {order.id}</p>
<p>合計金額: {order.total.toLocaleString("ja-JP")} 円</p>
</Modal>
);
}
メカニズムの説明:
Modalは開閉状態を管理するClient Componentです。- 内部のコンテンツ(
<p>注文番号...)はServer ComponentであるOrderPageでrenderされ、その後childrenとしてModalに渡されます。 - Next.jsはこの
children部分をまずサーバー側でrenderし、hydrate時にClient Componentの{children}位置に結果を「プラグイン」します。このため、OrderService.getOrderはクライアント化される必要はありません。
注意:
asyncServer Componentを直接importし、Client Component内で使用することはできません(例えば"use client"ファイル内でimport OrderDetail from "./OrderDetail")。Next.jsはOrderDetailがクライアント bundleに引き込まれたと見なし、async function componentがクライアント側上で有効ではないためエラーを報告します。childrenまたはpropsを通じたcompositionが正しい方法です。
6. Server ComponentからClient Componentへのデータの渡し方
Server ComponentからClient Componentに渡されるpropsはserializableである必要があります。つまり、JSONに変換できる必要があります。functionやclass instance、または複雑なDate objectは渡せません。
// app/components/PriceTag.tsx
"use client";
export default function PriceTag({ amount, currency }: { amount: number; currency: string }) {
return <span>{amount.toLocaleString("ja-JP")} {currency}</span>;
}
// app/products/[id]/page.tsx
import PriceTag from "@/app/components/PriceTag";
export default async function ProductDetailPage({ params }: { params: { id: string } }) {
const product = await fetch(`https://api.shop.local/products/${params.id}`).then(r => r.json());
return (
<div>
<h1>{product.name}</h1>
<PriceTag amount={product.price} currency="JPY" />
</div>
);
}
ここで、amount(number)とcurrency(string)は両方ともプリミティブデータであり、serialize可能です。Server ComponentからcallbackをfunctionとしてPriceTagに渡そうとすると、Next.jsはbuild/runtimeエラーを報告します:
Error: Functions cannot be passed directly to Client Components
unless you explicitly expose it by marking it with "use server".
サーバー側のロジックをクライアント側から呼び出したい場合(例えば、formをsubmit)、通常のfunctionを渡すのではなく、"use server"でマークされたServer Actionを使用します。
7. Server Action — 逆方向のブリッジ
Client Componentがサーバーをコールバック(データの書き込み、DBへのコール)する必要がある場合、API routeを別途作成するのではなく、Server Actionを使用します。
// app/actions/order-actions.ts
"use server";
export async function cancelOrder(orderId: string) {
await fetch(`https://api.shop.local/orders/${orderId}/cancel`, { method: "POST" });
}
// app/components/CancelButton.tsx
"use client";
import { cancelOrder } from "@/app/actions/order-actions";
import { useState } from "react";
export default function CancelButton({ orderId }: { orderId: string }) {
const [pending, setPending] = useState(false);
async function handleClick() {
setPending(true);
await cancelOrder(orderId);
setPending(false);
}
return (
<button onClick={handleClick} disabled={pending}>
{pending ? "キャンセル中..." : "注文をキャンセル"}
</button>
);
}
cancelOrderは"use server"でマークされており、Next.jsはClient Componentがサーバーをコールするためのhiddenなendpointを自動生成し、routeハンドラーを記述する必要はありません。CancelButtonはそれをimportして通常のfunctionのように呼び出します。実際にはnetwork callですが、codeは自然に読めます。
8. 分離されたロジックのテストを記述する
Server Componentにはfetch/transformロジックが含まれることが多いため、純粋なロジック(JSXなし)を分離するとcomponent render全体をテストするよりもテストが簡単になります。
// app/lib/pricing.ts
export function formatPrice(amount: number, currency: string): string {
if (amount < 0) {
throw new Error("amountは負にできません");
}
return `${amount.toLocaleString("ja-JP")} ${currency}`;
}
// app/lib/pricing.test.ts
import { describe, it, expect } from "vitest";
import { formatPrice } from "./pricing";
describe("formatPrice", () => {
it("有効な金額をフォーマットする", () => {
expect(formatPrice(150000, "JPY")).toBe("150,000 JPY");
});
it("amountが負の場合、エラーをスロー", () => {
expect(() => formatPrice(-1, "JPY")).toThrow("amountは負にできません");
});
});
プロジェクトがClient Component用にJest + Testing Libraryを使用する場合(例えばLikeButton)、通常のReact componentと同じようにテストします。Jestは"use client"について何も知る必要はありません。このdirectiveはNext.jsのbundlerにのみ意味があり、runtimeテストには影響しません。
// app/components/LikeButton.test.tsx
import { render, screen, fireEvent } from "@testing-library/react";
import { describe, it, expect } from "vitest";
import LikeButton from "./LikeButton";
describe("LikeButton", () => {
it("クリックすると状態が変わる", () => {
render(<LikeButton />);
const button = screen.getByRole("button");
expect(button).toHaveTextContent("気に入る");
fireEvent.click(button);
expect(button).toHaveTextContent("気に入った");
});
});
9. 一般的なエラー
App Routerに移行する際に遭遇する一般的なエラーはいくつかあります:
- hookを使用する際に
"use client"を忘れる: エラーuseState can not be used in Server Componentsが発生します。そのhookを含むファイルの先頭にdirectiveを追加します。それを呼び出すファイルではなく。 - componentツリーで
"use client"を高く配置しすぎる:layout.tsx全体をクライアント側としてマークすると、内部のすべて(子Server componentを含む)は直接importされた場合、client bundleに引き込まれ、bundleが不必要に膨らみます。必要最小限の最小componentのみをクライアント化する必要があります(例えば、ボタン、form input)。ページ全体ではなく。 - サーバー側からクライアント側にserialize不可能なfunctionまたはobjectをpropsで渡す—callbackを渡すのではなくServer Action(
"use server")を使用します。 "use client"ファイル内のServer Componentをimportし、それがサーバー側で実行されることを期待する—それはできません。children/propsを通じたcompositionを使用する必要があります。- Client Component内で秘密鍵を使用して
fetchを呼び出す—APIキーはブラウザに送信されるJS bundleに公開されます。秘密鍵が必要な操作は常にServer ComponentまたはServer Actionに配置する必要があります。
注意: 単に「古いcodeに似せるためにすべてをClient Componentに変換する」ことは安全ではありません。不要なClient Componentはそれぞれbundleサイズを増加させ、インタラクション時間(TTI)を遅くし、サーバー側でrenderする利点を失わせます。
10. 結論
Server ComponentはApp Routerのデフォルトであり、dataのfetch、機密リソースへのアクセス、およびJS bundleの削減に適しています。Client Componentは、state、effect、またはevent handlerがある場合にのみ使用する必要があります。この2つのtypeはcomposition(children、serializable props)とServer Actionを通じて結合され、Client Componentからサーバーロジックをコールバックするためのブリッジです。この境界を理解することで、bundleサイズを最適化し、機密データをサーバー側で安全に保つことができます。