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 のロードを待つ白画面」にはなりません。
核心的なルール:誰が誰をインポートできるか
この部分は最も間違いやすい場所です。
ルールはシンプルですが、多くの人は逆に覚えています:
- Server Component は Client Component をインポートできる ✅
- Client Component は Server Component をインポートできない ❌
- 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 など、内部で useState や useEffect を使用しています。なので、これらは 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>
)
}
エラーメッセージ:Button は useState を使用しているので、"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' })
キャッシュ戦略を合理的に選択し、過度のダイナミックレンダリングを避けます。
まとめ
これまで述べた内容をまとめると、核心的なポイントは以下の通りです:
- デフォルトで Server Components を使用し、インタラクションが必要な時のみ Client Components を使う
- Server は Client をインポートできるが、Client は Server をインポートできない
- children または props でデータを渡し、境界を明確に保つ
- “use client” をリーフノードに追加し、高レベルで乱用しない
- shadcn/ui コンポーネントを別途抽出し、Server Component 内に混ぜない
App Router の Server/Client 境界線は、開発者が「データに近く、ブラウザから遠く」なることを目的に設計されています。これを理解すれば、多くの困惑は自然に解決します。
シンプルなページから実践を始めることをおすすめします:先に Server Component を書いてデータを取得し、徐々にインタラクション部分を追加します。エラーが出ても慌てず、ほとんどは境界の問題です。コンポーネントのインポート関係をチェックすれば、すぐに問題を定位できます。
Server と Client Components の正しい混用方法
Next.js App Router プロジェクトで shadcn/ui を統合するベストプラクティス
⏱️ 目安時間: 30 分
- 1
ステップ1: コンポーネントタイプ要件を識別
各コンポーネントがインタラクションを必要とするか判断:
• イベント処理が必要(onClick、onChange) → Client Component
• React hooks が必要(useState、useEffect) → Client Component
• ブラウザ API が必要(localStorage、window) → Client Component
• データ表示のみ、インタラクションなし → Server Component(デフォルト) - 2
ステップ2: インタラクション部分をリーフノードとして抽出
インタラクションが必要な部分を別の Client Component として抽出:
• 新しいファイルを作成、先頭に 'use client' を追加
• shadcn/ui コンポーネントをインポート(Button、Dialog 等)
• この Client Component を Server Component でインポート
• props でデータを渡す - 3
ステップ3: データフローを設計
Server Component がデータを取得、Client Component に渡す:
• Server Component で async/await を使用してデータ取得
• props で Client Component に渡す
• 多くの場所で同じデータが必要な場合、React.cache() を使用
• Client Component で直接 headers()/cookies() を使用しない - 4
ステップ4: Context Provider を配置
Provider は Client Component でなければならないが、深い layout に置く:
• providers.tsx を作成、'use client' をマーク
• ThemeProvider、AuthProvider 等を包む
• 特定ルートの layout.tsx でインポート(root layout ではない)
• Client Component サブツリーの範囲を最小化 - 5
ステップ5: 検証と最適化
コンポーネント境界が正しいかチェック:
• 'use client' がリーフノードのみにあることを確認
• Client Component が Server Component をインポートしていないかチェック
• Suspense で async コンポーネントを包む
• 合理的な fetch キャッシュ戦略を設定
FAQ
shadcn/ui コンポーネントはなぜ Client Component を使う必要があるのか?
Server Component と Client Component は互いにインポートできるか?
複数の Server Component での同じデータの重複リクエストをどう防ぐか?
Context Provider はどこに置くべきか?
'useEffect only works in Client Component' エラーが出た時どうするか?
コンポーネントが Server か Client Component かどう判断するか?
シリーズ:この記事は Next.js 完全ガイド シリーズ(第46記事)の一部です。Next.js App Router を学習している場合、シリーズの他の記事もチェックしてください。shadcn/ui の実践テクニックについては、Tailwind & shadcn/ui 実践ガイド シリーズも参照してください。
4 min read · 公開日: 2026年3月31日 · 更新日: 2026年3月31日
関連記事
React Compiler + shadcn/ui:自動最適化時代のフロントエンド開発
React Compiler + shadcn/ui:自動最適化時代のフロントエンド開発
Astro + Tailwind:アイランドコンポーネントとグローバルスタイルの競合を防ぐ設定
Astro + Tailwind:アイランドコンポーネントとグローバルスタイルの競合を防ぐ設定
Nginx リバースプロキシ完全ガイド:upstream、バッファ、タイムアウト

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