App RouterではすべてのコンポーネントはデフォルトでServer Component。"use client"を書いた時だけClient Componentになる。データ取得はServer側に集約し、インタラクションが必要な末端だけをClient Componentにするのがベストプラクティス。
useState / useEffect / イベントハンドラ → Client Component が必要サーバー上で実行され、結果のHTMLだけがブラウザに届く。JSバンドルに含まれない。
ブラウザ Next.js サーバー
| |
| リクエスト → |
| コンポーネントを実行
| ↓
| HTML を生成
| ← HTML を受け取る |
| できること | Server Component |
|---|---|
| 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 = await 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になる。ブラウザで動き、Hydrationによりインタラクティブになる。
ブラウザ Next.js サーバー
| |
| リクエスト → |
| HTML を生成(初回)
| ← HTML を受け取る |
| |
| JS バンドルも受け取る
| ↓
| ブラウザ上で React が動く(Hydration)
| → useState / onClick が使えるようになる
"use client"
import { useState } from 'react'
export default function LikeButton({ postId }: { postId: string }) {
const [liked, setLiked] = useState(false)
return (
<button onClick={() => setLiked(true)}>
{liked ? '❤️ いいね済み' : '🤍 いいねする'}
</button>
)
}
💡 ポイント:
"use client"はファイル単位で宣言する。そのファイル以下のコンポーネントツリー全体がClient Componentになるので、できるだけ末端の小さいコンポーネントに留めるのがベストプラクティス。
迷ったらServer Componentがデフォルト。Clientが必要な理由がある時だけ"use client"をつける。
コンポーネントを作るとき
│
▼
useState / useEffect を使う?
イベントハンドラ(onClick 等)を使う?
ブラウザ API を使う?
│
YES │ NO
│
▼ ▼
Client DB アクセスや
Component 秘密鍵を使う?
│
YES │ NO
│
▼ ▼
Server どちらでも OK
Component → Server を選ぶ(デフォルト)
❌ よくある誤った設計
app/page.tsx
└── "use client" ← ページ全体を Client にしてしまう
├── Header
├── DataTable(本当は Server でよい)
└── LikeButton(これだけ Client が必要)
✅ 正しい設計
app/page.tsx(Server)
├── Header(Server)
├── DataTable(Server・DB アクセス)
└── LikeButton(Client・必要な部分だけ)
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。
フォーム送信や自アプリ内のDB書き込みはServer Actions、外部から叩かれるAPIやキャッシュが必要なエンドポイントはRoute Handlersを使う。
// lib/actions.ts
'use server'
import { revalidatePath } from 'next/cache'
import { createClient } from '@/lib/supabase/server'
export async function createPost(formData: FormData) {
const title = formData.get('title') as string
const supabase = await createClient()
const { error } = await supabase
.from('posts')
.insert({ title })
if (error) throw new Error(error.message)
revalidatePath('/posts') // キャッシュを即無効化
}
// app/posts/new/page.tsx
'use client'
import { createPost } from '@/lib/actions'
import { useTransition } from 'react'
export default function NewPostPage() {
const [isPending, startTransition] = useTransition()
return (
<form action={(fd) => startTransition(() => createPost(fd))}>
<input name="title" />
<button disabled={isPending}>保存</button>
</form>
)
}
// app/api/posts/route.ts
import { createClient } from '@/lib/supabase/server'
export async function GET(request: Request) {
const { searchParams } = new URL(request.url)
const status = searchParams.get('status') || 'published'
const supabase = await createClient()
const { data, error } = await supabase
.from('posts')
.select('*')
.eq('status', status)
if (error) return Response.json({ error: error.message }, { status: 500 })
return Response.json({ posts: data })
}
| シナリオ | 推奨 | 理由 |
|---|---|---|
| フォーム送信(自アプリのみ) | Server Actions | HTTPなし・型安全・シンプル |
| モバイルアプリと共用するAPI | Route Handlers | RESTful・外部から叩ける |
| CDNキャッシュしたいエンドポイント | Route Handlers | ISR対応 |
| 複雑な認証・バリデーション | Route Handlers | ミドルウェアで制御しやすい |
Next.jsのキャッシュは強力だが挙動を理解していないと意図しない古いデータが表示される。revalidateとrevalidatePathを使いこなす。
// ページ単位のキャッシュ制御
export const dynamic = 'force-static' // 完全静的(SSG)
export const revalidate = 3600 // ISR:1時間ごとに再生成
export const dynamic = 'force-dynamic' // 毎回生成(SSR)
// リクエスト内メモ化(同一リクエスト内で重複DBアクセスを防ぐ)
import { cache } from 'react'
const getPosts = cache(async () => {
const supabase = await createClient()
return supabase.from('posts').select('*')
})
// Server Actionでの即時キャッシュ無効化
import { revalidatePath, revalidateTag } from 'next/cache'
export async function createPost(formData: FormData) {
// ...DB書き込み...
revalidatePath('/posts') // パスを指定して無効化
revalidatePath('/') // トップも更新
}
💡 ポイント:
cookies()やheaders()をServer Componentで読むと自動的にDynamic(毎回生成)になる。認証が必要なページはこの挙動を利用する。
| ミス | 影響 | 対策 |
|---|---|---|
ページ全体に"use client"を書く | JSバンドルが肥大化・パフォーマンス低下 | インタラクションが必要な末端だけに限定 |
| Client Component内にServer Componentを直接import | Server ComponentがClient扱いになる | childrenやpropsとして外から渡す |
| サードパーティライブラリをServer Componentで使う | useState内蔵ライブラリはエラーになる | "use client"のラッパーコンポーネントを作る |
| Server → Clientに関数をpropsとして渡す | シリアライズエラー | 文字列・数値などJSON化できる値のみ渡す |
revalidatePathを呼び忘れる | 更新後も古いデータが表示され続ける | 書き込みServer Actionの末尾に必ず追加 |
NEXT_PUBLIC_を秘密鍵につける | クライアントに漏洩・セキュリティリスク | Server Componentでのみ使いプレフィックスをつけない |