Next.js App Router + shadcn/ui:サーバーコンポーネントとクライアントコンポーネントの併用ガイド
画面に一行のエラーが飛び込んできます: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" を付けたのに、なぜまだエラーが出るのでしょうか?
ドキュメントを延々と読み返して、ようやく問題がコンポーネントの import 境界にあると気づきました。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 キーをフロントエンドに一切露出させない)
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 の読み込みを待つ白い画面」にはなりません。
核心ルール:誰が誰を import できるか
ここが最もつまずきやすい部分です。
ルール自体はシンプルですが、多くの人が逆に覚えています:
- Server Component は Client Component を import できる ✅
- Client Component は Server Component を import できない ❌
- 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 で分離され、直接 import し合いません。
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
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') // 1回目のリクエスト
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
import { AcmeCarousel } from 'acme-carousel'
async function Page() {
return <AcmeCarousel /> // エラー!AcmeCarousel は内部で hooks を使っている
}
修正:ラッパーを作成します:
// 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:1時間ごとに再検証
await fetch(url, { next: { revalidate: 3600 } })
// 動的データ(毎回のリクエストで取得)
await fetch(url, { cache: 'no-store' })
キャッシュ戦略を適切に選び、過度な動的レンダリングを避けましょう。
まとめ
ここまで多くを語りましたが、核心はわずか数点です:
- デフォルトで Server Components を使い、インタラクションが必要なときだけ Client Components を使う
- Server は Client を import できるが、Client は Server を import できない
- children や props でデータを渡し、境界を明確に保つ
- “use client” は葉ノードに付け、上位階層で乱用しない
- shadcn/ui コンポーネントは個別に切り出し、Server Component に混ぜない
App Router の Server / Client の境界線は、開発者を「データに近く、ブラウザから遠く」するという設計思想から生まれました。これを理解すれば、多くの悩みはおのずと解けていきます。
まずはシンプルなページから実践することをおすすめします。最初に Server Component でデータを取得し、次にインタラクション部分を少しずつ追加していきましょう。エラーが出ても慌てず、たいていは境界の問題です。コンポーネントの import 関係を確認すれば、すぐに原因を特定できます。
シリーズについて:本記事は Next.js 完全ガイド シリーズ(第 46 回)の一部です。Next.js App Router を学んでいるなら、シリーズの他の記事もぜひご覧ください。shadcn/ui のさらなる実践テクニックについては、Tailwind と shadcn/ui 実践ガイド シリーズをおすすめします。
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 など)を import
• Server Component 内でこの Client Component を import
• 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 などをラップ
• root layout ではなく特定ルートの layout.tsx で import
• Client Component のサブツリー範囲を最小化する - 5
ステップ5: 検証と最適化
コンポーネント境界が正しいか確認します:
• 'use client' が葉ノードだけにあることを確認
• Client Component が Server Component を import していないか確認
• Suspense で非同期コンポーネントをラップ
• 適切な fetch キャッシュ戦略を設定
FAQ
なぜ shadcn/ui コンポーネントは必ず Client Component にする必要があるのですか?
Server Component と Client Component は互いに import できますか?
複数の Server Component で同じデータを重複リクエストしないようにするには?
Context Provider はどこに置くべきですか?
「useEffect は Client Component でしか使えない」というエラーが出たらどうすればいいですか?
あるコンポーネントを Server と Client のどちらにすべきか、どう判断しますか?
3分で読めます · 公開日: 2026年3月31日 · 更新日: 2026年6月8日
Next.js 完全ガイド
検索からこのページに来た場合は、前後の記事もあわせて読むと同じテーマの理解がかなり早く深まります。
前の記事
Next.js OAuth ログイン実践:Google・GitHub・WeChat 連携を NextAuth.js で設定
OAuth の仕組みを「荷物の代理受け取り」に例えて解説し、NextAuth.js で Google・GitHub・WeChat のサードパーティログインを実装。redirect_uri エラーなどのトラブルシューティングも網羅します。
第 45 / 47 記事
次の記事
React Server Components パフォーマンス最適化:データフェッチとキャッシュの実践
React Server Components パフォーマンス最適化の実践:ウォーターフォール問題からストリーミングアーキテクチャまで、データフェッチとキャッシュ戦略を詳しく解説。TTFB 450ms→45ms の最適化パスを提供し、4つのアプローチ比較と5つのキャッシュ API 使用ガイドを含む
第 47 / 47 記事
関連記事
Next.js App Router 入門ガイド:コア概念と基本操作を解説
Next.js App Router 入門ガイド:コア概念と基本操作を解説
Next.js 15 実践:週末で本番級ブログシステムを構築した方法
Next.js 15 実践:週末で本番級ブログシステムを構築した方法
Next.js Middleware 実践ガイド:パスマッチ、Edge Runtime 制限とよくある落とし穴
コメント
GitHubアカウントでログインしてコメントできます