言語を切り替える
テーマを切り替える

Next.js App Router + shadcn/ui:サーバーコンポーネントとクライアントコンポーネントの混用ガイド

深夜3時。画面上のエラーメッセージを見つめていました:Error: You're importing a component that needs useEffect. It only works in a Client Component but none of its parents are marked with "use client"

layout.tsx に "use client" を追加したのに、なぜまだエラーが出るのか?

ドキュメントを何時間も調べた後、問題はコンポーネントのインポート境界にあることがわかりました。App Router の Server Components と Client Components の境界線は、思っていたよりも遥かに複雑です。

これは多くの開発者が App Router に移行する際に直面する実際のシナリオです。フレームワークはデフォルトですべてのコンポーネントを Server Component としますが、UI ライブラリ(shadcn/ui など)はほとんど Client Component を必要とします。この境界をどう設定するのか?データの流れをどう設計するのか?パフォーマンスをどう最適化するのか?

この記事では、これらの問題を完全に解説します。


Server Components vs Client Components:根本的な違い

まず基本的な点から:App Router では、すべてのコンポーネントがデフォルトで Server Component です

これはどういう意味でしょうか?page.tsx や layout.tsx はデフォルトでサーバーサイドでレンダリングされ、ブラウザに JavaScript コードを一切送信しません。

Server Components でできること

Server Components の核心的な利点は「データに近い」ことです:

// app/products/page.tsx - Server Component (デフォルト)
async function ProductsPage() {
  // コンポーネント内で直接 await データ
  const products = await fetch('https://api.example.com/products', {
    next: { revalidate: 3600 } // 1時間キャッシュ
  }).then(res => res.json())

  return (
    <div>
      {products.map(p => (
        <div key={p.id}>{p.name} - ${p.price}</div>
      ))}
    </div>
  )
}

ご覧ください。useEffect なし、useState なし、直接 await でデータを取得できます。これが Server Components の「async コンポーネント」機能です。

適用シーン

  • データ取得(fetch、データベースクエリ)
  • バックエンド専用 API へのアクセス(headers()、cookies())
  • 大規模依存ライブラリ(markdown パーサー 100KB+ など、Server Component で使用すればブラウザにバンドルしない)
  • 機密情報の処理(API key をフロントエンドに露出しない)

Client Components でできること

Client Components は私たちが慣れ親しんだ「従来の React コンポーネント」です。ファイルの先頭に "use client" を追加するだけ:

// components/like-button.tsx
'use client'

import { useState } from 'react'

export function LikeButton({ postId }: { postId: string }) {
  const [liked, setLiked] = useState(false)
  const [count, setCount] = useState(0)

  const handleClick = () => {
    setLiked(!liked)
    setCount(prev => liked ? prev - 1 : prev + 1)
  }

  return (
    <button onClick={handleClick}>
      {liked ? '❤️' : '🤍'} {count}
    </button>
  )
}

適用シーン

  • イベント処理(onClick、onChange、onSubmit)
  • React hooks(useState、useEffect、useRef、useContext)
  • ブラウザ API(localStorage、window、document)
  • Context Provider

直感に反する点があります:Client Components もサーバーサイドで HTML としてプレレンダリングされます。その後ブラウザで hydrate され、インタラクション能力を回復します。なので、ユーザーは初回アクセス時に完全なコンテンツを見ることができ、「JS のロードを待つ白画面」にはなりません。


核心的なルール:誰が誰をインポートできるか

この部分は最も間違いやすい場所です。

ルールはシンプルですが、多くの人は逆に覚えています:

  1. Server Component は Client Component をインポートできる
  2. Client Component は Server Component をインポートできない
  3. Server Component は children として Client Component に渡せる

3つ目のルールは少し複雑です。コードを見ると明らかになります:

// app/page.tsx - Server Component
import { ClientContainer } from './client-container'
import { ServerData } from './server-data'

export default function Page() {
  return (
    <ClientContainer>
      {/* ServerData を children として渡す */}
      <ServerData />
    </ClientContainer>
  )
}

// client-container.tsx
'use client'

export function ClientContainer({ children }) {
  const [isOpen, setIsOpen] = useState(false)

  return (
    <div>
      <button onClick={() => setIsOpen(!isOpen)}>Toggle</button>
      {isOpen && children}
    </div>
  )
}

// server-data.tsx - Server Component
async function ServerData() {
  const data = await fetch('/api/data').then(r => r.json())
  return <div>{data.title}</div>
}

このパターンはよく見られます:Client Container がインタラクションロジックを担当し、Server Data がデータ取得を担当します。両者は children で隔離され、直接インポートしません。


shadcn/ui の統合:なぜこんなに「面倒」なのか

shadcn/ui は私が最も好きな UI ライブラリですが、App Router で使用するにはいくつかのトリックが必要です。

根本的な原因は:shadcn/ui は Radix UI ベースで、多くのコンポーネントが React hooks を使用しています

Button、Dialog、Dropdown Menu など、内部で useStateuseEffect を使用しています。なので、これらは Client Component でなければなりません。

間違った例:Server Component で直接 shadcn/ui を使用

// ❌ 間違い:Server Component が Client Component をインポート
import { Button } from '@/components/ui/button'

async function ProductPage() {
  const product = await fetchProduct()

  return (
    <div>
      <h1>{product.name}</h1>
      {/* これはエラーになる:Button は "use client" を必要 */}
      <Button onClick={() => addToCart(product.id)}>
        Add to Cart
      </Button>
    </div>
  )
}

エラーメッセージ:ButtonuseState を使用しているので、"use client" をマークする必要があります。

正しい解決策1:インタラクション部分を Client Component として抽出

最も一般的でシンプルなアプローチ:

// app/product/page.tsx - Server Component
import { ProductInfo } from './product-info'
import { AddToCartButton } from './add-to-cart-button'

async function ProductPage({ params }) {
  const product = await fetchProduct(params.id)

  return (
    <div>
      {/* Server Component:データ表示を担当 */}
      <ProductInfo product={product} />

      {/* Client Component:インタラクションを担当 */}
      <AddToCartButton productId={product.id} />
    </div>
  )
}

// product-info.tsx - Server Component
export function ProductInfo({ product }) {
  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <span>${product.price}</span>
    </div>
  )
}

// add-to-cart-button.tsx - Client Component
'use client'

import { Button } from '@/components/ui/button'
import { useState } from 'react'

export function AddToCartButton({ productId }) {
  const [loading, setLoading] = useState(false)

  const handleAdd = async () => {
    setLoading(true)
    await addToCart(productId)
    setLoading(false)
  }

  return (
    <Button onClick={handleAdd} disabled={loading}>
      {loading ? 'Adding...' : 'Add to Cart'}
    </Button>
  )
}

核心的な考え方:インタラクションが必要な部分を別のリーフノードとして抽出し、他の部分は Server Component を維持します。

正しい解決策2:コンポジションパターン(Server から Client にデータを渡す)

Client Component が初期データを必要とする場合:

// app/dashboard/page.tsx - Server Component
import { DataTable } from './data-table'

async function DashboardPage() {
  const users = await fetchUsers() // Server Component がデータを取得

  return <DataTable data={users} /> // Client Component に渡す
}

// data-table.tsx - Client Component
'use client'

import { Table } from '@/components/ui/table'
import { useState } from 'react'

export function DataTable({ data }) {
  const [selectedRows, setSelectedRows] = useState([])

  return (
    <Table>
      {/* shadcn/ui Table コンポーネント */}
      <TableBody>
        {data.map(user => (
          <TableRow
            key={user.id}
            selected={selectedRows.includes(user.id)}
            onClick={() => toggleSelection(user.id)}
          >
            <TableCell>{user.name}</TableCell>
          </TableRow>
        ))}
      </TableBody>
    </Table>
  )
}

これで Server Component のデータ取得利点と Client Component のインタラクション能力を両立できます。


Context Provider をどこに置くか

もう一つのよくある困惑:グローバル Context Provider(ThemeProvider、AuthProvider など)はどこに置くべきでしょうか?

答えは:Client Component に置く必要がありますが、「できるだけ深く」置くこと

// app/layout.tsx - Server Component (root layout)
export default function RootLayout({ children }) {
  return (
    <html>
      <body>
        {/* ここに Provider を置かない */}
        {children}
      </body>
    </html>
  )
}

// app/providers.tsx - Client Component
'use client'

import { ThemeProvider } from 'next-themes'
import { AuthProvider } from './auth-context'

export function Providers({ children }) {
  return (
    <ThemeProvider>
      <AuthProvider>
        {children}
      </AuthProvider>
    </ThemeProvider>
  )
}

// app/dashboard/layout.tsx - Server Component
import { Providers } from '../providers'

export default function DashboardLayout({ children }) {
  return (
    <Providers>
      {children}
    </Providers>
  )
}

なぜ「深く」置くのか?Provider は包んだすべてのコンポーネントを Client Component のサブツリーに変えてしまうからです。root layout に置くと、アプリ全体がクライアントサイドレンダリングに強制されます。

より深いレベル(特定のルートの layout など)に置くことで、Provider の影響範囲を最小化できます。


データフロー:Server から Client への伝達

Props は最もシンプルで最も信頼性の高い方法です:

// Server Component がデータを取得
const data = await fetchData()

// Client Component に渡す
<ClientComponent initialData={data} />

しかし、パフォーマンス最適化のポイントがあります:React.cache() 関数

複数の Server Component が同じデータを必要とする場合、cache で重複リクエストを防止できます:

// lib/get-user.ts
import { cache } from 'react'

export const getUser = cache(async (id: string) => {
  return await db.query('SELECT * FROM users WHERE id = ?', [id])
})

// app/layout.tsx
async function Layout() {
  const user = await getUser('123') // 最初のリクエスト
  return <header>{user.name}</header>
}

// app/page.tsx
async function Page() {
  const user = await getUser('123') // 同じパラメータ、重複リクエストしない
  return <main>Welcome {user.name}</main>
}

cache は単一のレンダリングサイクル内で、同じパラメータの呼び出しを自動的にデデュープ(重複排除)します。


4つの最もよくあるエラー

エラー1:高レベルで “use client” を乱用

// ❌ app/layout.tsx に "use client" を追加
'use client'

export default function Layout({ children }) {
  return <div>{children}</div>
}

これにより、アプリ全体のサブツリーが Client Component になり、Server Component のパフォーマンス利点を失います。

修正:本当にインタラクションが必要なコンポーネントのみに "use client" を追加し、リーフノードで維持します。

エラー2:Server Component で hooks を使用

// ❌ Server Component で useState を使用
async function Page() {
  const [count, setCount] = useState(0) // エラー!
  return <div>{count}</div>
}

修正:hooks を必要とする部分を Client Component として抽出します。

エラー3:Client Component で headers()/cookies() を使用

// ❌ Client Component でサーバー API を使用
'use client'

import { headers } from 'next/headers'

function UserProfile() {
  const headersList = headers() // エラー!Server Component でしか使えない
  return <div>...</div>
}

修正:Server Component でデータを取得し、Client Component に渡します:

// Server Component が headers を取得
async function Page() {
  const userAgent = headers().get('user-agent')
  return <UserProfile userAgent={userAgent} />
}

// Client Component がデータを受け取る
'use client'
function UserProfile({ userAgent }) {
  return <div>Browser: {userAgent}</div>
}

エラー4:サードパーティコンポーネントが “use client” をマークしていない

// ❌ Server Component がマークされていないサードパーティコンポーネントをインポート
import { AcmeCarousel } from 'acme-carousel'

async function Page() {
  return <AcmeCarousel /> // エラー!AcmeCarousel は内部で hooks を使用
}

修正:wrapper を作成します:

// components/carousel-wrapper.tsx
'use client'

import { AcmeCarousel } from 'acme-carousel'

export function CarouselWrapper(props) {
  return <AcmeCarousel {...props} />
}

// page.tsx - Server Component
import { CarouselWrapper } from './carousel-wrapper'

async function Page() {
  return <CarouselWrapper /> // 正常に動作
}

パフォーマンス最適化のヒント

最後にいくつかの実践的なテクニック:

1. Client Components をリーフノードに置く

このルールでクライアントサイド JavaScript を 70% 減少できます。

製品リストページの例:

  • 製品グリッド:Server Component
  • 各製品カード:Server Component
  • カード上の数量セレクター:Client Component(唯一のインタラクション部分)

2. Suspense でストリーミングレンダリング

// app/page.tsx
import { Suspense } from 'react'
import { ProductList } from './product-list'
import { Recommendations } from './recommendations'

export default function Page() {
  return (
    <div>
      {/* 先にスケルトンを表示、データが来たら置換 */}
      <Suspense fallback={<ProductSkeleton />}>
        <ProductList />
      </Suspense>

      {/* Secondary content は独立してストリーミングレンダリング */}
      <Suspense fallback={<RecSkeleton />}>
        <Recommendations />
      </Suspense>
    </div>
  )
}

ユーザーは先にページの枠組みを見ることができ、データが徐々に充填されます。「すべてのデータがロードされるまで待つ」より遥かに良い体験です。

3. fetch キャッシュ戦略

// 静的データ(ビルド時に取得)
await fetch(url, { cache: 'force-cache' })

// ISR:毎時間再検証
await fetch(url, { next: { revalidate: 3600 } })

// ダイナミックデータ(毎リクエストで取得)
await fetch(url, { cache: 'no-store' })

キャッシュ戦略を合理的に選択し、過度のダイナミックレンダリングを避けます。


まとめ

これまで述べた内容をまとめると、核心的なポイントは以下の通りです:

  1. デフォルトで Server Components を使用し、インタラクションが必要な時のみ Client Components を使う
  2. Server は Client をインポートできるが、Client は Server をインポートできない
  3. children または props でデータを渡し、境界を明確に保つ
  4. “use client” をリーフノードに追加し、高レベルで乱用しない
  5. shadcn/ui コンポーネントを別途抽出し、Server Component 内に混ぜない

App Router の Server/Client 境界線は、開発者が「データに近く、ブラウザから遠く」なることを目的に設計されています。これを理解すれば、多くの困惑は自然に解決します。

シンプルなページから実践を始めることをおすすめします:先に Server Component を書いてデータを取得し、徐々にインタラクション部分を追加します。エラーが出ても慌てず、ほとんどは境界の問題です。コンポーネントのインポート関係をチェックすれば、すぐに問題を定位できます。

Server と Client Components の正しい混用方法

Next.js App Router プロジェクトで shadcn/ui を統合するベストプラクティス

⏱️ 目安時間: 30 分

  1. 1

    ステップ1: コンポーネントタイプ要件を識別

    各コンポーネントがインタラクションを必要とするか判断:

    • イベント処理が必要(onClick、onChange) → Client Component
    • React hooks が必要(useState、useEffect) → Client Component
    • ブラウザ API が必要(localStorage、window) → Client Component
    • データ表示のみ、インタラクションなし → Server Component(デフォルト)
  2. 2

    ステップ2: インタラクション部分をリーフノードとして抽出

    インタラクションが必要な部分を別の Client Component として抽出:

    • 新しいファイルを作成、先頭に 'use client' を追加
    • shadcn/ui コンポーネントをインポート(Button、Dialog 等)
    • この Client Component を Server Component でインポート
    • props でデータを渡す
  3. 3

    ステップ3: データフローを設計

    Server Component がデータを取得、Client Component に渡す:

    • Server Component で async/await を使用してデータ取得
    • props で Client Component に渡す
    • 多くの場所で同じデータが必要な場合、React.cache() を使用
    • Client Component で直接 headers()/cookies() を使用しない
  4. 4

    ステップ4: Context Provider を配置

    Provider は Client Component でなければならないが、深い layout に置く:

    • providers.tsx を作成、'use client' をマーク
    • ThemeProvider、AuthProvider 等を包む
    • 特定ルートの layout.tsx でインポート(root layout ではない)
    • Client Component サブツリーの範囲を最小化
  5. 5

    ステップ5: 検証と最適化

    コンポーネント境界が正しいかチェック:

    • 'use client' がリーフノードのみにあることを確認
    • Client Component が Server Component をインポートしていないかチェック
    • Suspense で async コンポーネントを包む
    • 合理的な fetch キャッシュ戦略を設定

FAQ

shadcn/ui コンポーネントはなぜ Client Component を使う必要があるのか?
shadcn/ui は Radix UI ベースです。多くのコンポーネントは内部で React hooks(useState、useContext 等)を使用して状態管理やイベント処理を行っています。これらの hooks はブラウザ環境でしか動作しないため、'use client' マークが必要です。
Server Component と Client Component は互いにインポートできるか?
Server Component は Client Component をインポートできますが、Client Component は Server Component をインポートできません。ただし、children プロパティで Server Component を Client Component に渡すことで、両者が協力して動作できます。
複数の Server Component での同じデータの重複リクエストをどう防ぐか?
React.cache() 関数でデータ取得ロジックを包みます。同じパラメータの呼び出しは単一レンダリングサイクル内で自動的に重複排除され、重複したデータベースクエリや API リクエストを防止できます。
Context Provider はどこに置くべきか?
Provider は Client Component でなければなりません(React Context に依存)。しかし、root layout に置かないでください。特定ルートの layout.tsx で Provider をインポートすることを推奨します。これにより Client Component サブツリーの範囲を最小化し、より多くのコンポーネントの Server Component 利点を保持できます。
'useEffect only works in Client Component' エラーが出た時どうするか?
エラーが出ているコンポーネントのインポートチェーンをチェック:hooksやイベント処理を使用しているコンポーネントを見つけ、ファイル先頭に 'use client' を追加。サードパーティコンポーネントがマークされていない場合、wrapper コンポーネントを作成して 'use client' をマークし、その後インポート。
コンポーネントが Server か Client Component かどう判断するか?
シンプルな判断:onClick、onChange 等のインタラクションが必要 → Client。useState、useEffect 等の hooks が必要 → Client。localStorage、window 等のブラウザ API が必要 → Client。その他の場合はデフォルトで Server Component。

シリーズ:この記事は Next.js 完全ガイド シリーズ(第46記事)の一部です。Next.js App Router を学習している場合、シリーズの他の記事もチェックしてください。shadcn/ui の実践テクニックについては、Tailwind & shadcn/ui 実践ガイド シリーズも参照してください。

4 min read · 公開日: 2026年3月31日 · 更新日: 2026年3月31日

コメント

GitHubアカウントでログインしてコメントできます

関連記事