"use client" を書いた時だけ Client Component になるuseState / useEffect / イベントハンドラ → Client Component が必要サーバー上で実行され、結果の HTML だけがブラウザに届く。JS バンドルに含まれない。
ブラウザ Next.js サーバー
| |
| リクエスト → |
| コンポーネントを実行
| ↓
| HTML を生成
| ← HTML を受け取る |
| できること | Server Component | Django View との比較 |
|---|---|---|
| DB アクセス | ✅ 直接 OK | ✅ |
| 環境変数(秘密鍵)利用 | ✅ 安全 | ✅ |
| useState / useEffect | ❌ 使えない | — |
| イベントハンドラ(onClick) | ❌ 使えない | — |
| ブラウザ API(localStorage 等) | ❌ 使えない | — |
// app/users/page.tsx
// "use client" がない → Server Component
import { createClient } from '@/lib/supabase/server'
export default async function UsersPage() {
const supabase = createClient()
const { data: users } = await supabase.from('users').select('*')
// サーバー上で直接 DB アクセス。ブラウザには結果の HTML だけ届く
return (
<ul>
{users?.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)
}
💡 ポイント: Supabase の
service_role_keyを使う処理は Server Component に書けば安全。NEXT_PUBLIC_をつけた瞬間にクライアントに漏れるので注意。
"use client" をファイル先頭に書くと Client Component になる。従来の React(Pages Router)の感覚に近い。
ブラウザ Next.js サーバー
| |
| リクエスト → |
| HTML を生成(初回)
| ← HTML を受け取る |
| |
| JS バンドルも受け取る
| ↓
| ブラウザ上で React が動く(Hydration)
| → useState / onClick が使えるようになる
| できること | Server Component | Client Component |
|---|---|---|
| useState / useEffect | ❌ | ✅ |
| onClick などのイベント | ❌ | ✅ |
| ブラウザ API(localStorage) | ❌ | ✅ |
| async/await で DB アクセス | ✅ | ❌ |
| 秘密鍵を安全に使う | ✅ | ❌ |
"use client"
import { useState } from 'react'
export default function LikeButton({ postId }: { postId: string }) {
const [liked, setLiked] = useState(false)
const handleLike = async () => {
setLiked(true)
// Supabase へは API Route 経由で呼び出す(直接アクセスしない)
await fetch('/api/likes', {
method: 'POST',
body: JSON.stringify({ postId }),
})
}
return (
<button onClick={handleLike}>
{liked ? '❤️ いいね済み' : '🤍 いいねする'}
</button>
)
}
💡 ポイント:
"use client"はファイル単位で宣言する。そのファイル以下のコンポーネントツリー全体が Client Component になるので、できるだけ末端の小さいコンポーネントに留めるのがベストプラクティス。
コンポーネントを作るとき
│
▼
useState / useEffect を使う?
イベントハンドラ(onClick 等)を使う?
ブラウザ API を使う?
│
YES │ NO
│
▼ ▼
Client DB アクセスや
Component 秘密鍵を使う?
│
YES │ NO
│
▼ ▼
Server どちらでも OK
Component → Server を選ぶ(デフォルト)
迷ったら Server Component が基本方針。
| B 案(検索も Client) | C 案(検索は Server) | |
|---|---|---|
| 検索のたびにページ遷移 | なし(リアルタイム検索) | あり(URL クエリで検索) |
| JS バンドルサイズ | やや大きい | 小さい |
| 向いているケース | インクリメンタルサーチ | シンプルな検索フォーム |
// C 案のイメージ(検索を Server 側で処理)
export default async function UsersPage({
searchParams
}: {
searchParams: { q?: string }
}) {
const { data: users } = await supabase
.from('users')
.ilike('name', `%${searchParams.q ?? ''}%`)
return (
<>
<SearchBox /> {/* Client Component(入力欄)*/}
{users?.map(user => (
<UserCard key={user.id} user={user}>
<FollowButton userId={user.id} /> {/* Client Component */}
</UserCard>
))}
</>
)
}
💡 ポイント: 「検索 = 必ず Client Component」ではない。リアルタイム性が必要かどうかで判断する。
Client Component の中で Server Component を直接 import するとNG。children として渡すのが正しい。
// ❌ 悪い例
"use client"
import HeavyDataTable from './HeavyDataTable' // Server Component のつもり
export default function Modal() {
const [open, setOpen] = useState(false)
return (
<div>
<button onClick={() => setOpen(!open)}>開く</button>
{open && <HeavyDataTable />} {/* Client として扱われてしまう! */}
</div>
)
}
// ✅ 良い例:children として渡す
// components/Modal.tsx(Client Component)
"use client"
export default function Modal({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(false)
return (
<div>
<button onClick={() => setOpen(!open)}>開く</button>
{open && children}
</div>
)
}
// app/posts/page.tsx(Server Component)
import Modal from '@/components/Modal'
import HeavyDataTable from '@/components/HeavyDataTable'
export default function Page() {
return (
<Modal>
<HeavyDataTable /> {/* Server Component を children として渡す */}
</Modal>
)
}
Page(Server)
└── Modal(Client)
└── children = HeavyDataTable(Server)✅
💡 ポイント: 「誰が import するか」が重要。Client Component の中に Server Component を import で直接入れるとNG、children や props として外から渡すのは OK。
"use client" の伝播"use client"
// このファイルで import されたコンポーネントはすべて Client 扱いになる
import ComponentA from './ComponentA' // → Client 扱いになる
// ❌ 内部で useState を使うライブラリを Server Component で使うとエラー
import { Toaster } from 'react-hot-toast'
// ✅ ラッパーを作る
// components/ToasterWrapper.tsx
"use client"
import { Toaster } from 'react-hot-toast'
export default function ToasterWrapper() {
return <Toaster />
}
// ❌ 関数は渡せない(シリアライズ不可)
return <Button onClick={() => console.log('hi')} />
// ✅ 文字列・数値・配列・オブジェクトは OK
return <Button label="クリック" count={5} />
"use client" を書きすぎる❌ よくある誤った設計
app/page.tsx
└── "use client" ← ページ全体を Client にしてしまう
├── Header
├── DataTable(本当は Server でよい)
└── LikeButton(これだけ Client が必要)
✅ 正しい設計
app/page.tsx(Server)
├── Header(Server)
├── DataTable(Server・DB アクセス)
└── LikeButton(Client・必要な部分だけ)
| ミス | 改善策 |
|---|---|
Server Component で useState を使う | "use client" を追加する |
ページ全体に "use client" を書く | インタラクションが必要な末端コンポーネントだけに限定する |
| Client Component 内で Server Component を直接 import | children や props として外から渡す |
| サードパーティライブラリをそのまま Server Component で使う | "use client" のラッパーコンポーネントを作る |
| Server → Client に関数を props として渡す | 文字列・数値など JSON 化できる値のみ渡す |
NEXT_PUBLIC_ を秘密鍵につける | Server Component 内のみで使い、プレフィックスをつけない |
- Server Component は「Django の View」に近い感覚。HTML を返すだけ、状態は持たない。
"use client"は境界線を引くイメージ。その境界より下はすべて Client 扱い。- 「誰が import するか」がコンポジションパターンの鍵。
- 検索は「リアルタイム性が必要か」で Server / Client を判断する。
service_role_keyは絶対にNEXT_PUBLIC_をつけない(RLS をバイパスするため)。