T-CREATOR

shadcn/ui × RSC 時代のフロント設計:Server/Client の最適境界を見極める指針

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コードの完全な所有権コンポーネントのコードを自由にカスタマイズ可能★★★
2Radix UI ベースアクセシビリティに優れた基盤★★☆
3インタラクティブ性多くのコンポーネントが状態管理を必要とする★☆☆
4TypeScript サポート型安全な開発が可能★★★

shadcn/ui のコンポーネントの多くは、useStateuseEffect などの React Hooks を使用しているため、基本的には Client Component として動作します。 しかし、適切に設計することで、RSC の恩恵を最大限に受けられるのです。

Server Component と Client Component の違い

RSC 環境では、コンポーネントを Server Component と Client Component の 2 種類に分類します。 それぞれの特徴を理解することが、最適な設計の第一歩になりますね。

#項目Server ComponentClient 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 として動作します。 結果として、以下の問題が発生してしまいます。

#問題影響
1JavaScript バンドルサイズの増加初期読み込みが遅くなる
2Hydration コストの上昇インタラクティブになるまでの時間が増加
3サーバー側の最適化が効かないデータフェッチが非効率に
4SEO への悪影響初期 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>
  );
}

この設計により、以下の最適化が実現できます。

#コンポーネントタイプ理由
1ProductPageServerデータフェッチを効率化
2ProductInfoServer静的コンテンツのみ
3ProductActionsClientボタンのクリックイベント
4ProductImagesClient画像の切り替え機能
5ReviewsSectionServer静的なレビュー表示

実例 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 なしでも動作可能)

パフォーマンス測定と最適化の指標

実装後は、以下の指標でパフォーマンスを測定しましょう。

#指標目標値測定方法
1JavaScript バンドルサイズ100KB 以下Next.js Bundle Analyzer
2First Contentful Paint (FCP)1.8 秒以下Lighthouse
3Time to Interactive (TTI)3.8 秒以下Lighthouse
4Total Blocking Time (TBT)200ms 以下Lighthouse
5Cumulative 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 つのチェックポイント

  1. 状態管理(useState、useReducer など)が必要か
  2. ユーザーイベント(onClick、onChange など)を扱うか
  3. ブラウザ API(window、document など)を使うか
  4. 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 の境界設計に戸惑うかもしれませんが、本記事で紹介したパターンを実践していくうちに、自然と最適な設計が見えてくるはずです。 ぜひ、実際のプロジェクトで試してみてくださいね。

関連リンク