shadcn/ui × RSC 時代のフロント設計:Server/Client の最適境界を見極める指針
Next.js の App Router と React Server Components(RSC)の登場により、フロントエンド開発の設計思想は大きく変わりました。
従来の Pages Router では「すべてがクライアントコンポーネント」という前提で設計していましたが、RSC 時代では「デフォルトはサーバーコンポーネント」に転換しています。 この変化は、shadcn/ui のような UI コンポーネントライブラリを使う際にも大きな影響を与えます。
本記事では、shadcn/ui を RSC 環境で効果的に活用するための設計指針をお伝えします。 Server Component と Client Component の最適な境界線を見極め、パフォーマンスと開発体験を両立させる方法を具体例とともに解説していきましょう。
背景
RSC がもたらしたパラダイムシフト
React Server Components は、React 18 で導入された新しいレンダリングアーキテクチャです。 これまでのクライアントサイドレンダリング中心の設計から、サーバーとクライアントの役割を明確に分離する設計へと進化しました。
Next.js の App Router は、この RSC を標準として採用し、デフォルトですべてのコンポーネントがサーバーコンポーネントとして動作するようになっています。
以下の図は、従来の Pages Router と新しい App Router におけるレンダリングの違いを示しています。
mermaidflowchart TB
subgraph pagesRouter["Pages Router(従来)"]
pr_browser["ブラウザ"] -->|初回アクセス| pr_ssr["SSR 処理"]
pr_ssr -->|HTML 生成| pr_hydrate["Hydration"]
pr_hydrate -->|すべて Client に| pr_client["クライアント実行"]
end
subgraph appRouter["App Router(RSC)"]
ar_browser["ブラウザ"] -->|初回アクセス| ar_rsc["Server Component"]
ar_rsc -->|HTML + RSC ペイロード| ar_selective["選択的 Hydration"]
ar_selective -->|必要な部分のみ| ar_client["Client Component"]
end
pagesRouter -.->|進化| appRouter
この変化により、以下のメリットが得られるようになりました。
- JavaScript バンドルサイズの削減: サーバーコンポーネントのコードはクライアントに送信されない
- データフェッチの最適化: サーバー側で直接データベースや API にアクセス可能
- 初期表示速度の向上: クライアントでの処理が最小限になり、Time to Interactive が改善
shadcn/ui の特徴と RSC との相性
shadcn/ui は、Radix UI と Tailwind CSS をベースにした UI コンポーネントライブラリです。 他のライブラリと異なり、npm パッケージとしてインストールするのではなく、コンポーネントのソースコードをプロジェクトに直接コピーする方式を採用しています。
shadcn/ui の主な特徴は以下のとおりです。
| # | 特徴 | 説明 | RSC との相性 |
|---|---|---|---|
| 1 | コードの完全な所有権 | コンポーネントのコードを自由にカスタマイズ可能 | ★★★ |
| 2 | Radix UI ベース | アクセシビリティに優れた基盤 | ★★☆ |
| 3 | インタラクティブ性 | 多くのコンポーネントが状態管理を必要とする | ★☆☆ |
| 4 | TypeScript サポート | 型安全な開発が可能 | ★★★ |
shadcn/ui のコンポーネントの多くは、useState や useEffect などの React Hooks を使用しているため、基本的には Client Component として動作します。
しかし、適切に設計することで、RSC の恩恵を最大限に受けられるのです。
Server Component と Client Component の違い
RSC 環境では、コンポーネントを Server Component と Client Component の 2 種類に分類します。 それぞれの特徴を理解することが、最適な設計の第一歩になりますね。
| # | 項目 | Server Component | Client Component |
|---|---|---|---|
| 1 | 実行環境 | サーバー側のみ | サーバー+クライアント |
| 2 | 使える機能 | async/await、直接 DB アクセス | useState、useEffect、イベントハンドラ |
| 3 | バンドルサイズ | クライアントに送信されない | JavaScript としてクライアントに送信 |
| 4 | 再レンダリング | ページ遷移時のみ | 状態変更時に即座に |
| 5 | データフェッチ | サーバー側で完結 | クライアント側で fetch |
Server Component では、以下のような処理が可能です。
typescript// Server Component の例
// async/await を直接使用できる
async function UserProfile({ userId }: { userId: string }) {
// データベースへの直接アクセス
const user = await db.user.findUnique({
where: { id: userId },
});
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
);
}
一方、Client Component では以下のように明示的に宣言する必要があります。
typescript// Client Component の例
'use client'; // この宣言が必要
import { useState } from 'react';
export function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(count + 1)}>
カウント: {count}
</button>
);
}
この違いを理解し、適材適所で使い分けることが、RSC 時代のフロント設計の鍵となります。
課題
shadcn/ui コンポーネントはほぼ Client Component
shadcn/ui のコンポーネントを実際にプロジェクトに追加すると、多くのファイルの先頭に 'use client' ディレクティブが記述されていることに気づきます。
例えば、よく使われる Button コンポーネントを見てみましょう。
typescript// components/ui/button.tsx
'use client';
import * as React from 'react';
import { Slot } from '@radix-ui/react-slot';
import {
cva,
type VariantProps,
} from 'class-variance-authority';
// ... 型定義など
typescript// Button コンポーネントの実装
const Button = React.forwardRef<
HTMLButtonElement,
ButtonProps
>(
(
{ className, variant, size, asChild = false, ...props },
ref
) => {
const Comp = asChild ? Slot : 'button';
return (
<Comp
className={cn(
buttonVariants({ variant, size, className })
)}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = 'Button';
export { Button, buttonVariants };
この Button コンポーネント自体は状態管理を行っていませんが、'use client' が付いています。
理由は、Radix UI の Slot コンポーネントがクライアント側の機能を使用しているためです。
すべてを Client Component にすると失われるもの
shadcn/ui のコンポーネントをそのまま使うと、コンポーネントツリー全体が Client Component になってしまう可能性があります。 以下の図は、不適切な設計でクライアント境界が広がってしまうケースを示しています。
mermaidflowchart TD
root["app/page.tsx<br/>(Server)"] --> layout["ProductLayout<br/>(Server)"]
layout --> card["ProductCard<br/>('use client')"]
card --> title["ProductTitle<br/>(自動的に Client)"]
card --> desc["ProductDescription<br/>(自動的に Client)"]
card --> badge["Badge<br/>(shadcn/ui)"]
card --> button["Button<br/>(shadcn/ui)"]
style card fill:#ffcccc
style title fill:#ffcccc
style desc fill:#ffcccc
style badge fill:#ffcccc
style button fill:#ffcccc
この設計では、ProductCard に 'use client' を付けた時点で、その子コンポーネントすべてが Client Component として動作します。
結果として、以下の問題が発生してしまいます。
| # | 問題 | 影響 |
|---|---|---|
| 1 | JavaScript バンドルサイズの増加 | 初期読み込みが遅くなる |
| 2 | Hydration コストの上昇 | インタラクティブになるまでの時間が増加 |
| 3 | サーバー側の最適化が効かない | データフェッチが非効率に |
| 4 | SEO への悪影響 | 初期 HTML に含まれるコンテンツが減少 |
特に、商品一覧ページなど、多数のコンポーネントをレンダリングする場面では、この影響が顕著に現れます。
コンポーネント境界の判断が難しい
RSC 環境で最も難しいのが、「どこを Server Component にして、どこを Client Component にするか」という境界の判断です。
以下のようなシナリオでは、判断に迷うことが多いでしょう。
シナリオ 1:インタラクティブな要素が一部だけある場合
typescript// ダイアログの中身はほぼ静的だが、
// 開閉ボタンだけがインタラクティブ
function ProductDetail({ product }) {
return (
<Dialog>
<DialogTrigger>詳細を見る</DialogTrigger>
<DialogContent>
{/* この中身はほぼ静的 */}
<h2>{product.name}</h2>
<p>{product.description}</p>
<Image src={product.image} />
</DialogContent>
</Dialog>
);
}
シナリオ 2:条件付きでインタラクティブになる場合
typescript// ログイン状態によってボタンの動作が変わる
function ActionButton({ isLoggedIn, productId }) {
if (!isLoggedIn) {
// 静的なリンク
return (
<Button asChild>
<Link href='/login'>ログイン</Link>
</Button>
);
}
// インタラクティブなボタン
return (
<Button onClick={() => addToCart(productId)}>
カートに追加
</Button>
);
}
シナリオ 3:データフェッチとインタラクションの混在
typescript// サーバーでデータを取得したいが、
// フィルタリングはクライアントで行いたい
function ProductList() {
// サーバーでデータ取得?
const products = await fetchProducts();
// クライアントでフィルタリング?
const [filtered, setFiltered] = useState(products);
return (
<div>
<SearchInput onChange={handleFilter} />
{filtered.map((product) => (
<ProductCard key={product.id} {...product} />
))}
</div>
);
}
これらのシナリオでは、パフォーマンス、ユーザー体験、開発効率のバランスを考えながら、最適な境界を見極める必要があります。
パフォーマンスと開発体験のトレードオフ
RSC を活用してパフォーマンスを最適化しようとすると、コードの複雑さが増す傾向があります。
以下の表は、設計アプローチごとのトレードオフをまとめたものです。
| # | アプローチ | パフォーマンス | 開発体験 | メンテナンス性 |
|---|---|---|---|---|
| 1 | すべて Client Component | ★☆☆ | ★★★ | ★★★ |
| 2 | すべて Server Component | ★★★ | ★☆☆ | ★☆☆ |
| 3 | 細かく境界を分割 | ★★★ | ★★☆ | ★★☆ |
| 4 | ページ単位で分割 | ★★☆ | ★★★ | ★★★ |
理想は「細かく境界を分割」ですが、以下のようなコストが発生します。
- コンポーネントの分割が増え、ファイル数が増加
- props のバケツリレーが発生しやすい
- コンポーネント間の依存関係が複雑化
- テストケースの増加
特に、チーム開発では「どこまで最適化するか」の基準を明確にしておかないと、メンバーごとに実装方針がバラバラになってしまう可能性があります。
解決策
Server/Client 境界の基本戦略
RSC 環境で shadcn/ui を効果的に使うための基本戦略は、「Client Component の境界をできるだけ下層に押し下げる」 ことです。
以下の図は、最適化された境界設計を示しています。
mermaidflowchart TD
root["app/page.tsx<br/>(Server)"] --> layout["ProductLayout<br/>(Server)"]
layout --> static["ProductInfo<br/>(Server)"]
layout --> interactive["InteractiveSection<br/>(Client 境界)"]
static --> title["Title(Server)"]
static --> desc["Description(Server)"]
interactive --> button["Button<br/>(shadcn/ui)"]
interactive --> dialog["Dialog<br/>(shadcn/ui)"]
style root fill:#ccffcc
style layout fill:#ccffcc
style static fill:#ccffcc
style title fill:#ccffcc
style desc fill:#ccffcc
style interactive fill:#ffffcc
style button fill:#ffcccc
style dialog fill:#ffcccc
この設計では、以下のメリットが得られます。
- 静的なコンテンツは Server Component として効率的にレンダリング
- インタラクティブな部分だけを Client Component に限定
- JavaScript バンドルサイズの最小化
- サーバー側での最適化(データフェッチ、キャッシング)が効く
判断基準:4 つのチェックポイント
コンポーネントを Server と Client のどちらにするか迷ったときは、以下の 4 つのチェックポイントで判断しましょう。
チェックポイント 1:状態管理が必要か
typescript// ❌ Client Component が必要
function SearchBox() {
const [query, setQuery] = useState(''); // useState を使用
return (
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
);
}
typescript// ✅ Server Component で OK
function StaticTitle({ title }: { title: string }) {
return <h1 className='text-2xl font-bold'>{title}</h1>;
}
チェックポイント 2:ユーザーイベントを扱うか
typescript// ❌ Client Component が必要
function AddToCartButton({
productId,
}: {
productId: string;
}) {
return (
<Button onClick={() => addToCart(productId)}>
カートに追加
</Button>
);
}
typescript// ✅ Server Component で OK(リンクのみ)
function ProductLink({
href,
name,
}: {
href: string;
name: string;
}) {
return (
<Link href={href} className='text-blue-600'>
{name}
</Link>
);
}
チェックポイント 3:ブラウザ API を使うか
typescript// ❌ Client Component が必要
'use client';
function WindowSize() {
const [size, setSize] = useState({ width: 0, height: 0 });
useEffect(() => {
const handleResize = () => {
setSize({
width: window.innerWidth,
height: window.innerHeight,
});
};
window.addEventListener('resize', handleResize);
return () =>
window.removeEventListener('resize', handleResize);
}, []);
return (
<div>
{size.width} x {size.height}
</div>
);
}
チェックポイント 4:shadcn/ui コンポーネントを直接使うか
typescript// ❌ Client Component が必要(shadcn/ui を使用)
'use client';
import { Button } from '@/components/ui/button';
function ActionPanel() {
return <Button>実行</Button>;
}
typescript// ✅ Server Component で OK(HTML のみ)
function StaticPanel() {
return (
<button className='px-4 py-2 bg-blue-500 text-white'>
実行
</button>
);
}
これら 4 つのうち、1 つでも該当する場合は Client Component にする必要があります。
パターン 1:Wrapper パターン
最も基本的なパターンが、Client Component でインタラクティブな部分だけをラップする方法です。
まず、shadcn/ui のコンポーネントを使う部分を Client Component として分離します。
typescript// components/product/add-to-cart-button.tsx
'use client';
import { Button } from '@/components/ui/button';
import { useToast } from '@/components/ui/use-toast';
interface AddToCartButtonProps {
productId: string;
productName: string;
}
export function AddToCartButton({
productId,
productName,
}: AddToCartButtonProps) {
const { toast } = useToast();
const handleAddToCart = () => {
// カートに追加する処理
addToCartAction(productId);
toast({
title: 'カートに追加しました',
description: `${productName} をカートに追加しました`,
});
};
return (
<Button onClick={handleAddToCart}>カートに追加</Button>
);
}
次に、Server Component でこのボタンを使います。
typescript// app/products/[id]/page.tsx
import { AddToCartButton } from '@/components/product/add-to-cart-button';
// Server Component
async function ProductPage({
params,
}: {
params: { id: string };
}) {
// サーバー側でデータフェッチ
const product = await fetchProduct(params.id);
return (
<div className='container mx-auto p-6'>
{/* 静的なコンテンツは Server Component */}
<h1 className='text-3xl font-bold'>{product.name}</h1>
<p className='text-gray-600 mt-2'>
{product.description}
</p>
<div className='text-2xl font-semibold mt-4'>
¥{product.price.toLocaleString()}
</div>
{/* インタラクティブな部分だけ Client Component */}
<div className='mt-6'>
<AddToCartButton
productId={product.id}
productName={product.name}
/>
</div>
</div>
);
}
export default ProductPage;
このパターンの利点は以下のとおりです。
- ページ全体のほとんどが Server Component として動作
- 静的コンテンツの HTML はサーバーで生成され、SEO に有利
- JavaScript バンドルには
AddToCartButtonとその依存関係のみが含まれる
パターン 2:Composition パターン
children を活用して、Server Component の中に Client Component を配置する方法です。
まず、Client Component で外枠だけを定義します。
typescript// components/ui/interactive-card.tsx
'use client';
import { Card } from '@/components/ui/card';
import { useState } from 'react';
interface InteractiveCardProps {
children: React.ReactNode;
expandable?: boolean;
}
export function InteractiveCard({
children,
expandable = false,
}: InteractiveCardProps) {
const [isExpanded, setIsExpanded] = useState(false);
if (!expandable) {
return <Card>{children}</Card>;
}
return (
<Card>
<div
className={
isExpanded ? '' : 'max-h-48 overflow-hidden'
}
>
{children}
</div>
<button
onClick={() => setIsExpanded(!isExpanded)}
className='text-blue-600 mt-2'
>
{isExpanded ? '折りたたむ' : 'もっと見る'}
</button>
</Card>
);
}
次に、Server Component で children として静的コンテンツを渡します。
typescript// app/articles/page.tsx
import { InteractiveCard } from '@/components/ui/interactive-card';
// Server Component
async function ArticlesPage() {
// サーバー側でデータフェッチ
const articles = await fetchArticles();
return (
<div className='grid grid-cols-1 md:grid-cols-2 gap-4'>
{articles.map((article) => (
<InteractiveCard key={article.id} expandable>
{/* この中身は Server Component として動作 */}
<h2 className='text-xl font-bold'>
{article.title}
</h2>
<p className='text-gray-600 mt-2'>
{article.excerpt}
</p>
<div className='flex gap-2 mt-4'>
{article.tags.map((tag) => (
<span
key={tag}
className='px-2 py-1 bg-gray-100 rounded'
>
{tag}
</span>
))}
</div>
</InteractiveCard>
))}
</div>
);
}
export default ArticlesPage;
このパターンでは、以下の利点があります。
- children の中身は Server Component として評価される
- 静的コンテンツが HTML として事前レンダリング
- インタラクティブな機能(展開/折りたたみ)のみがクライアントで動作
パターン 3:Props Drilling 回避パターン
深いコンポーネントツリーで、途中のコンポーネントが props を中継するだけの場合、Composition パターンで解決できます。
以下は、アンチパターンの例です。
typescript// ❌ すべて Client Component になってしまう
'use client';
function Page() {
const [selectedId, setSelectedId] = useState('');
return (
<Layout
selectedId={selectedId}
onSelect={setSelectedId}
>
<Sidebar
selectedId={selectedId}
onSelect={setSelectedId}
>
<Menu
selectedId={selectedId}
onSelect={setSelectedId}
>
<MenuItem id='1' />
<MenuItem id='2' />
</Menu>
</Sidebar>
</Layout>
);
}
改善版では、インタラクティブな部分だけを Client Component にします。
typescript// app/dashboard/page.tsx
// Server Component
import { DashboardLayout } from '@/components/dashboard/layout';
import { DashboardSidebar } from '@/components/dashboard/sidebar';
async function DashboardPage() {
const user = await getCurrentUser();
const stats = await getStats();
return (
<DashboardLayout
sidebar={<DashboardSidebar user={user} />}
>
{/* メインコンテンツは Server Component */}
<div>
<h1>ダッシュボード</h1>
<StatsCards stats={stats} />
</div>
</DashboardLayout>
);
}
typescript// components/dashboard/layout.tsx
// Server Component(children を受け取るだけ)
export function DashboardLayout({
sidebar,
children,
}: {
sidebar: React.ReactNode;
children: React.ReactNode;
}) {
return (
<div className='flex'>
<aside className='w-64'>{sidebar}</aside>
<main className='flex-1'>{children}</main>
</div>
);
}
typescript// components/dashboard/sidebar.tsx
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
export function DashboardSidebar({ user }: { user: User }) {
const [activeMenu, setActiveMenu] = useState('home');
return (
<nav>
<div className='p-4'>
<p className='font-semibold'>{user.name}</p>
</div>
<ul>
<li>
<Button
variant={
activeMenu === 'home' ? 'default' : 'ghost'
}
onClick={() => setActiveMenu('home')}
>
ホーム
</Button>
</li>
<li>
<Button
variant={
activeMenu === 'settings'
? 'default'
: 'ghost'
}
onClick={() => setActiveMenu('settings')}
>
設定
</Button>
</li>
</ul>
</nav>
);
}
この設計では、以下のメリットが得られます。
DashboardLayoutは Server Component のまま- 状態管理は
DashboardSidebarの中だけに閉じ込められている - メインコンテンツは Server Component として効率的にレンダリング
パターン 4:カスタム Server Component ラッパー
shadcn/ui のコンポーネントの中でも、インタラクティブな機能を使わない場合は、Server Component 版を作成できます。
typescript// components/ui/server-button.tsx
// Server Component 版のボタン
import { type VariantProps } from 'class-variance-authority';
import { buttonVariants } from '@/components/ui/button';
import { cn } from '@/lib/utils';
interface ServerButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
export function ServerButton({
className,
variant,
size,
...props
}: ServerButtonProps) {
return (
<button
className={cn(
buttonVariants({ variant, size, className })
)}
{...props}
/>
);
}
この Server Component 版ボタンは、以下のような場面で使えます。
typescript// app/products/page.tsx
import { ServerButton } from '@/components/ui/server-button';
import Link from 'next/link';
// Server Component
async function ProductsPage() {
const products = await fetchProducts();
return (
<div>
<h1>商品一覧</h1>
<div className='grid grid-cols-3 gap-4'>
{products.map((product) => (
<div key={product.id}>
<h2>{product.name}</h2>
<p>¥{product.price}</p>
{/* Link の中で使う場合は Server Component で OK */}
<Link href={`/products/${product.id}`}>
<ServerButton variant='outline'>
詳細を見る
</ServerButton>
</Link>
</div>
))}
</div>
</div>
);
}
ただし、この方法には注意点があります。
onClickなどのイベントハンドラは使えない- Radix UI の機能(Slot など)は使えない
- あくまで見た目だけのボタンとして使う
用途に応じて、通常の Button(Client Component)と使い分けましょう。
具体例
実例 1:商品詳細ページの実装
実際の商品詳細ページを例に、Server/Client の境界設計を見ていきます。
以下は、このページの構造を示した図です。
mermaidflowchart TB
page["app/products/[id]/page.tsx<br/>(Server Component)"] --> layout["レイアウト全体(Server)"]
layout --> images["ProductImages<br/>(Client)"]
layout --> info["ProductInfo<br/>(Server)"]
layout --> reviews["ReviewsSection<br/>(Server)"]
info --> title["タイトル(Server)"]
info --> price["価格(Server)"]
info --> actions["ProductActions<br/>(Client)"]
actions --> cartBtn["AddToCartButton"]
actions --> wishBtn["WishlistButton"]
reviews --> list["ReviewList(Server)"]
reviews --> form["ReviewForm<br/>(Client)"]
style page fill:#ccffcc
style layout fill:#ccffcc
style info fill:#ccffcc
style title fill:#ccffcc
style price fill:#ccffcc
style reviews fill:#ccffcc
style list fill:#ccffcc
style images fill:#ffffcc
style actions fill:#ffffcc
style form fill:#ffffcc
style cartBtn fill:#ffcccc
style wishBtn fill:#ffcccc
まず、ページ全体を Server Component として定義します。
typescript// app/products/[id]/page.tsx
import { notFound } from 'next/navigation';
import { ProductImages } from '@/components/product/product-images';
import { ProductInfo } from '@/components/product/product-info';
import { ReviewsSection } from '@/components/product/reviews-section';
import { fetchProduct, fetchReviews } from '@/lib/api';
interface ProductPageProps {
params: {
id: string;
};
}
// Server Component
async function ProductPage({ params }: ProductPageProps) {
// サーバー側で並列にデータフェッチ
const [product, reviews] = await Promise.all([
fetchProduct(params.id),
fetchReviews(params.id),
]);
if (!product) {
notFound();
}
return (
<div className='container mx-auto px-4 py-8'>
<div className='grid grid-cols-1 md:grid-cols-2 gap-8'>
{/* 画像ギャラリー(Client Component) */}
<ProductImages images={product.images} />
{/* 商品情報(Server Component) */}
<ProductInfo product={product} />
</div>
{/* レビューセクション(Server Component) */}
<div className='mt-12'>
<ReviewsSection
productId={product.id}
reviews={reviews}
/>
</div>
</div>
);
}
export default ProductPage;
次に、商品情報の部分を実装します。 こちらはほぼ静的なので Server Component にします。
typescript// components/product/product-info.tsx
import { Badge } from '@/components/ui/badge';
import { ProductActions } from './product-actions';
import type { Product } from '@/types';
interface ProductInfoProps {
product: Product;
}
// Server Component
export function ProductInfo({ product }: ProductInfoProps) {
return (
<div>
{/* タイトルエリア */}
<div>
<h1 className='text-3xl font-bold'>
{product.name}
</h1>
<div className='flex gap-2 mt-2'>
{product.tags.map((tag) => (
<Badge key={tag} variant='secondary'>
{tag}
</Badge>
))}
</div>
</div>
{/* 価格エリア */}
<div className='mt-6'>
<div className='text-3xl font-bold'>
¥{product.price.toLocaleString()}
</div>
{product.originalPrice && (
<div className='text-lg text-gray-500 line-through'>
¥{product.originalPrice.toLocaleString()}
</div>
)}
</div>
{/* 説明 */}
<div className='mt-6'>
<h2 className='text-xl font-semibold'>商品説明</h2>
<p className='mt-2 text-gray-700 leading-relaxed'>
{product.description}
</p>
</div>
{/* アクションボタン(Client Component) */}
<div className='mt-8'>
<ProductActions
productId={product.id}
productName={product.name}
inStock={product.inStock}
/>
</div>
</div>
);
}
アクションボタンの部分だけを Client Component にします。
typescript// components/product/product-actions.tsx
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { useToast } from '@/components/ui/use-toast';
import { Heart, ShoppingCart } from 'lucide-react';
import { addToCart, toggleWishlist } from '@/lib/actions';
interface ProductActionsProps {
productId: string;
productName: string;
inStock: boolean;
}
export function ProductActions({
productId,
productName,
inStock,
}: ProductActionsProps) {
const [isWishlisted, setIsWishlisted] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const { toast } = useToast();
const handleAddToCart = async () => {
setIsLoading(true);
try {
await addToCart(productId);
toast({
title: 'カートに追加しました',
description: `${productName} をカートに追加しました`,
});
} catch (error) {
toast({
title: 'エラーが発生しました',
description: 'もう一度お試しください',
variant: 'destructive',
});
} finally {
setIsLoading(false);
}
};
const handleToggleWishlist = async () => {
try {
await toggleWishlist(productId);
setIsWishlisted(!isWishlisted);
toast({
title: isWishlisted
? 'お気に入りから削除しました'
: 'お気に入りに追加しました',
});
} catch (error) {
toast({
title: 'エラーが発生しました',
variant: 'destructive',
});
}
};
return (
<div className='flex gap-4'>
<Button
className='flex-1'
size='lg'
onClick={handleAddToCart}
disabled={!inStock || isLoading}
>
<ShoppingCart className='mr-2 h-5 w-5' />
{inStock ? 'カートに追加' : '在庫切れ'}
</Button>
<Button
variant='outline'
size='lg'
onClick={handleToggleWishlist}
>
<Heart
className={`h-5 w-5 ${
isWishlisted ? 'fill-red-500 text-red-500' : ''
}`}
/>
</Button>
</div>
);
}
画像ギャラリーは、スワイプやズームなどのインタラクティブ機能が必要なので Client Component にします。
typescript// components/product/product-images.tsx
'use client';
import { useState } from 'react';
import Image from 'next/image';
import { Card } from '@/components/ui/card';
interface ProductImagesProps {
images: string[];
}
export function ProductImages({
images,
}: ProductImagesProps) {
const [selectedIndex, setSelectedIndex] = useState(0);
return (
<div>
{/* メイン画像 */}
<Card className='overflow-hidden'>
<div className='relative aspect-square'>
<Image
src={images[selectedIndex]}
alt='商品画像'
fill
className='object-cover'
priority
/>
</div>
</Card>
{/* サムネイル */}
<div className='grid grid-cols-4 gap-2 mt-4'>
{images.map((image, index) => (
<button
key={index}
onClick={() => setSelectedIndex(index)}
className={`relative aspect-square rounded-md overflow-hidden border-2 ${
index === selectedIndex
? 'border-blue-500'
: 'border-transparent'
}`}
>
<Image
src={image}
alt={`サムネイル ${index + 1}`}
fill
className='object-cover'
/>
</button>
))}
</div>
</div>
);
}
この設計により、以下の最適化が実現できます。
| # | コンポーネント | タイプ | 理由 |
|---|---|---|---|
| 1 | ProductPage | Server | データフェッチを効率化 |
| 2 | ProductInfo | Server | 静的コンテンツのみ |
| 3 | ProductActions | Client | ボタンのクリックイベント |
| 4 | ProductImages | Client | 画像の切り替え機能 |
| 5 | ReviewsSection | Server | 静的なレビュー表示 |
実例 2:ダイアログの最適化
shadcn/ui の Dialog コンポーネントは Client Component ですが、中身は Server Component にできます。
まず、ダイアログのトリガー部分を Client Component にします。
typescript// components/product/product-details-dialog.tsx
'use client';
import { useState } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
interface ProductDetailsDialogProps {
children: React.ReactNode; // Server Component を受け取る
triggerLabel?: string;
}
export function ProductDetailsDialog({
children,
triggerLabel = '詳細を見る',
}: ProductDetailsDialogProps) {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant='outline'>{triggerLabel}</Button>
</DialogTrigger>
<DialogContent className='max-w-2xl max-h-[80vh] overflow-y-auto'>
<DialogHeader>
<DialogTitle>商品詳細</DialogTitle>
<DialogDescription>
商品の詳しい情報をご確認いただけます
</DialogDescription>
</DialogHeader>
{/* children は Server Component として評価される */}
{children}
</DialogContent>
</Dialog>
);
}
次に、ダイアログの中身を Server Component として定義します。
typescript// components/product/product-details-content.tsx
import { Badge } from '@/components/ui/badge';
import type { Product } from '@/types';
interface ProductDetailsContentProps {
product: Product;
}
// Server Component
export function ProductDetailsContent({
product,
}: ProductDetailsContentProps) {
return (
<div className='space-y-6'>
{/* スペック情報 */}
<div>
<h3 className='font-semibold text-lg mb-3'>
製品スペック
</h3>
<table className='w-full'>
<tbody className='divide-y'>
{Object.entries(product.specifications).map(
([key, value]) => (
<tr key={key}>
<td className='py-2 font-medium text-gray-600'>
{key}
</td>
<td className='py-2'>{value}</td>
</tr>
)
)}
</tbody>
</table>
</div>
{/* 特徴 */}
<div>
<h3 className='font-semibold text-lg mb-3'>特徴</h3>
<ul className='list-disc list-inside space-y-2'>
{product.features.map((feature, index) => (
<li key={index} className='text-gray-700'>
{feature}
</li>
))}
</ul>
</div>
{/* カテゴリー */}
<div>
<h3 className='font-semibold text-lg mb-3'>
カテゴリー
</h3>
<div className='flex flex-wrap gap-2'>
{product.categories.map((category) => (
<Badge key={category} variant='secondary'>
{category}
</Badge>
))}
</div>
</div>
</div>
);
}
最後に、ページで組み合わせます。
typescript// app/products/page.tsx
import { ProductDetailsDialog } from '@/components/product/product-details-dialog';
import { ProductDetailsContent } from '@/components/product/product-details-content';
async function ProductsPage() {
const products = await fetchProducts();
return (
<div className='grid grid-cols-1 md:grid-cols-3 gap-6'>
{products.map((product) => (
<div
key={product.id}
className='border rounded-lg p-4'
>
<h2 className='text-xl font-bold'>
{product.name}
</h2>
<p className='text-gray-600 mt-2'>
{product.shortDescription}
</p>
{/* Dialog の外枠は Client、中身は Server */}
<ProductDetailsDialog>
<ProductDetailsContent product={product} />
</ProductDetailsDialog>
</div>
))}
</div>
);
}
この設計のメリットは以下のとおりです。
- ダイアログの開閉状態管理は Client Component
- ダイアログの中身(商品詳細)は Server Component として HTML で配信
- ダイアログを開くまで、中身の JavaScript は読み込まれない
- 初期表示のバンドルサイズが最小限
実例 3:フォームの段階的実装
フォームは段階的に Client Component 化することで、最適なバランスが取れます。
以下の図は、フォームの Server/Client 境界を示しています。
mermaidflowchart TB
page["問い合わせページ<br/>(Server)"] --> form["ContactForm<br/>(Client 境界)"]
form --> inputs["入力フィールド群<br/>(Client)"]
form --> validation["バリデーション<br/>(Client)"]
form --> submit["送信処理<br/>(Server Action)"]
inputs --> name["名前入力"]
inputs --> email["メール入力"]
inputs --> message["メッセージ入力"]
submit --> api["API 呼び出し<br/>(Server)"]
api --> db[("データベース")]
style page fill:#ccffcc
style submit fill:#ccffcc
style api fill:#ccffcc
style db fill:#ccffcc
style form fill:#ffffcc
style inputs fill:#ffcccc
style validation fill:#ffcccc
style name fill:#ffcccc
style email fill:#ffcccc
style message fill:#ffcccc
まず、Server Action を定義します。
typescript// app/contact/actions.ts
'use server';
import { z } from 'zod';
import { sendEmail } from '@/lib/email';
const contactSchema = z.object({
name: z.string().min(1, '名前を入力してください'),
email: z
.string()
.email('有効なメールアドレスを入力してください'),
message: z
.string()
.min(10, 'メッセージは10文字以上入力してください'),
});
export async function submitContact(formData: FormData) {
// バリデーション
const data = {
name: formData.get('name'),
email: formData.get('email'),
message: formData.get('message'),
};
const result = contactSchema.safeParse(data);
if (!result.success) {
return {
success: false,
errors: result.error.flatten().fieldErrors,
};
}
// メール送信処理(サーバー側で実行)
try {
await sendEmail({
to: 'support@example.com',
subject: `お問い合わせ: ${result.data.name}`,
body: result.data.message,
replyTo: result.data.email,
});
return { success: true };
} catch (error) {
return {
success: false,
errors: {
_form: [
'送信に失敗しました。もう一度お試しください。',
],
},
};
}
}
次に、フォームコンポーネントを Client Component として実装します。
typescript// components/contact/contact-form.tsx
'use client';
import { useState } from 'react';
import { useFormStatus } from 'react-dom';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { useToast } from '@/components/ui/use-toast';
import { submitContact } from '@/app/contact/actions';
function SubmitButton() {
const { pending } = useFormStatus();
return (
<Button
type='submit'
disabled={pending}
className='w-full'
>
{pending ? '送信中...' : '送信する'}
</Button>
);
}
export function ContactForm() {
const [errors, setErrors] = useState<
Record<string, string[]>
>({});
const { toast } = useToast();
async function handleSubmit(formData: FormData) {
// Server Action を呼び出し
const result = await submitContact(formData);
if (result.success) {
toast({
title: '送信完了',
description: 'お問い合わせを受け付けました',
});
setErrors({});
// フォームをリセット
(
document.getElementById(
'contact-form'
) as HTMLFormElement
)?.reset();
} else {
setErrors(result.errors || {});
toast({
title: '送信失敗',
description: 'エラーが発生しました',
variant: 'destructive',
});
}
}
return (
<form
id='contact-form'
action={handleSubmit}
className='space-y-6'
>
{/* 名前入力 */}
<div className='space-y-2'>
<Label htmlFor='name'>お名前</Label>
<Input
id='name'
name='name'
placeholder='山田 太郎'
required
/>
{errors.name && (
<p className='text-sm text-red-600'>
{errors.name[0]}
</p>
)}
</div>
{/* メールアドレス入力 */}
<div className='space-y-2'>
<Label htmlFor='email'>メールアドレス</Label>
<Input
id='email'
name='email'
type='email'
placeholder='example@example.com'
required
/>
{errors.email && (
<p className='text-sm text-red-600'>
{errors.email[0]}
</p>
)}
</div>
{/* メッセージ入力 */}
<div className='space-y-2'>
<Label htmlFor='message'>お問い合わせ内容</Label>
<Textarea
id='message'
name='message'
placeholder='お問い合わせ内容を入力してください'
rows={6}
required
/>
{errors.message && (
<p className='text-sm text-red-600'>
{errors.message[0]}
</p>
)}
</div>
{/* 送信ボタン */}
<SubmitButton />
{errors._form && (
<p className='text-sm text-red-600 text-center'>
{errors._form[0]}
</p>
)}
</form>
);
}
最後に、ページで使用します。
typescript// app/contact/page.tsx
import { ContactForm } from '@/components/contact/contact-form';
// Server Component
function ContactPage() {
return (
<div className='container mx-auto max-w-2xl px-4 py-12'>
<div className='space-y-6'>
{/* 静的コンテンツは Server Component */}
<div>
<h1 className='text-3xl font-bold'>
お問い合わせ
</h1>
<p className='text-gray-600 mt-2'>
ご質問やご要望がございましたら、以下のフォームからお気軽にお問い合わせください。
通常、24時間以内にご返信いたします。
</p>
</div>
{/* フォームは Client Component */}
<div className='bg-white rounded-lg border p-6'>
<ContactForm />
</div>
{/* その他の情報も Server Component */}
<div className='text-sm text-gray-600'>
<h2 className='font-semibold mb-2'>
プライバシーポリシー
</h2>
<p>
お預かりした個人情報は、お問い合わせへの回答のみに使用し、
第三者への開示や他の目的での使用は一切いたしません。
</p>
</div>
</div>
</div>
);
}
export default ContactPage;
このフォーム実装の特徴は以下のとおりです。
- フォームの入力管理とバリデーション表示は Client Component
- 実際の送信処理は Server Action として実行
- ページの説明文などは Server Component として配信
- Progressive Enhancement に対応(JavaScript なしでも動作可能)
パフォーマンス測定と最適化の指標
実装後は、以下の指標でパフォーマンスを測定しましょう。
| # | 指標 | 目標値 | 測定方法 |
|---|---|---|---|
| 1 | JavaScript バンドルサイズ | 100KB 以下 | Next.js Bundle Analyzer |
| 2 | First Contentful Paint (FCP) | 1.8 秒以下 | Lighthouse |
| 3 | Time to Interactive (TTI) | 3.8 秒以下 | Lighthouse |
| 4 | Total Blocking Time (TBT) | 200ms 以下 | Lighthouse |
| 5 | Cumulative Layout Shift (CLS) | 0.1 以下 | Lighthouse |
バンドルサイズの分析には、以下のコマンドを使用します。
bash# Next.js のバンドルアナライザーをインストール
yarn add -D @next/bundle-analyzer
javascript// next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')(
{
enabled: process.env.ANALYZE === 'true',
}
);
module.exports = withBundleAnalyzer({
// 既存の設定
});
bash# バンドルサイズを分析
ANALYZE=true yarn build
この分析により、どのコンポーネントが大きなバンドルサイズを占めているかが可視化されます。 shadcn/ui のコンポーネントを Client Component として使っている部分を特定し、Server Component に移行できないか検討しましょう。
まとめ
shadcn/ui と RSC を組み合わせたフロント設計では、Server Component と Client Component の境界を適切に設定することが成功の鍵となります。
本記事で解説した重要なポイントをまとめます。
設計の基本原則
- Client Component の境界はできるだけ下層に押し下げる
- 静的コンテンツは積極的に Server Component として実装
- インタラクティブな機能が必要な部分だけを Client Component にする
判断の 4 つのチェックポイント
- 状態管理(useState、useReducer など)が必要か
- ユーザーイベント(onClick、onChange など)を扱うか
- ブラウザ API(window、document など)を使うか
- shadcn/ui のコンポーネントを直接使うか
実践的なパターン
- Wrapper パターン: インタラクティブな部分だけを Client Component で包む
- Composition パターン: children を活用して Server Component を埋め込む
- Props Drilling 回避: Composition で深い階層の props 中継を回避
- カスタムラッパー: 必要に応じて Server Component 版のコンポーネントを作成
パフォーマンスの最適化
- JavaScript バンドルサイズを継続的に監視
- Lighthouse などのツールで Core Web Vitals を測定
- Server Action を活用してクライアント側の処理を削減
RSC は単なる技術的な変更ではなく、フロントエンド開発の設計思想そのものを変える大きな転換点です。 shadcn/ui のような優れた UI ライブラリと組み合わせることで、パフォーマンスとユーザー体験の両方を高いレベルで実現できるでしょう。
最初は Server/Client の境界設計に戸惑うかもしれませんが、本記事で紹介したパターンを実践していくうちに、自然と最適な設計が見えてくるはずです。 ぜひ、実際のプロジェクトで試してみてくださいね。
関連リンク
articleshadcn/ui × RSC 時代のフロント設計:Server/Client の最適境界を見極める指針
articleshadcn/ui のテンプレート差分を追従する運用:更新検知・差分マージ・回帰防止
articleshadcn/ui で Tailwind クラスが競合する時の対処法:優先度・レイヤ・@apply の整理
articleshadcn/ui で B2B SaaS ダッシュボードを組む:権限別 UI と監査ログの見せ方
articleshadcn/ui で Command Palette を実装:検索・履歴・キーボードショートカット対応
articleshadcn/ui の asChild/Slot を極める:継承可能な API 設計と責務分離
articleVite プラグインフック対応表:Rollup → Vite マッピング早見表
articleNestJS Monorepo 構築:Nx/Yarn Workspaces で API・Lib を一元管理
articleTypeScript Project References 入門:大規模 Monorepo で高速ビルドを実現する設定手順
articleMySQL Router セットアップ完全版:アプリからの透過フェイルオーバーを実現
articletRPC アーキテクチャ設計:BFF とドメイン分割で肥大化を防ぐルータ戦略
articleMotion(旧 Framer Motion)× TypeScript:Variant 型と Props 推論を強化する設定レシピ
blogiPhone 17シリーズの発表!全モデルiPhone 16から進化したポイントを見やすく整理
blogGoogleストアから訂正案内!Pixel 10ポイント有効期限「1年」表示は誤りだった
blog【2025年8月】Googleストア「ストアポイント」は1年表記はミス?2年ルールとの整合性を検証
blogGoogleストアの注文キャンセルはなぜ起きる?Pixel 10購入前に知るべき注意点
blogPixcel 10シリーズの発表!全モデル Pixcel 9 から進化したポイントを見やすく整理
blogフロントエンドエンジニアの成長戦略:コーチングで最速スキルアップする方法
review今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
reviewついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
review愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
review週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
review新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
review科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来