// ✅ これは自動的に Server Component
export default function ArticleList() {
// Server でのみ実行
const articles = await getArticles(); // DB 直接アクセス可能
return (
<div>
{articles.map(article => (
<div key={article.id}>{article.title}</div>
))}
</div>
);
}
メリット:
デメリット:
useState, useEffect など client hooks 使えない'use client'; // ← これが必須
import { useState, useEffect } from 'react';
export default function Counter() {
// Client で実行
const [count, setCount] = useState(0);
useEffect(() => {
console.log('Count:', count);
}, [count]);
return (
<button onClick={() => setCount(count + 1)}>
Count: {count}
</button>
);
}
メリット:
デメリット:
// ✅ Server Component を使うべき
export default async function Page() {
const data = await fetchFromDB(); // Server でのみ
return (
<div>
<Title>{data.title}</Title>
<Counter /> {/* ← Client Component をネスト */}
</div>
);
}
// ✅ Client Component は小さく
'use client';
export function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}
ベストプラクティス:
Top Level: Server Component
└─ UI Layout
└─ Data Fetching
└─ Client Component (インタラクティブ部分のみ)
【lib/actions.ts】
'use server';
import { supabase } from '@/lib/supabase';
// Client Component から直接呼び出せる
export async function createArticle(formData: FormData) {
const title = formData.get('title') as string;
const content = formData.get('content') as string;
const { error } = await supabase
.from('articles')
.insert({ title, content });
if (error) throw new Error(error.message);
revalidatePath('/'); // キャッシュ無効化
}
【app/articles/new/page.tsx】
'use client';
import { createArticle } from '@/lib/actions';
import { useTransition } from 'react';
export default function NewArticle() {
const [isPending, startTransition] = useTransition();
const handleSubmit = (formData: FormData) => {
startTransition(() => createArticle(formData));
};
return (
<form action={handleSubmit}>
<input name="title" />
<textarea name="content" />
<button disabled={isPending}>保存</button>
</form>
);
}
メリット:
デメリット:
【app/api/articles/route.ts】
import { supabase } from '@/lib/supabase';
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const status = searchParams.get('status') || 'published';
const { data, error } = await supabase
.from('articles')
.select('*')
.eq('status', status);
if (error) return Response.json({ error: error.message }, { status: 500 });
return Response.json({ articles: data });
}
export async function POST(request: Request) {
const { title, content } = await request.json();
const { data, error } = await supabase
.from('articles')
.insert({ title, content })
.select()
.single();
if (error) return Response.json({ error: error.message }, { status: 500 });
return Response.json({ article: data }, { status: 201 });
}
【Client Component】
'use client';
import { useEffect, useState } from 'react';
export function ArticleList() {
const [articles, setArticles] = useState([]);
useEffect(() => {
fetch('/api/articles?status=published')
.then(r => r.json())
.then(d => setArticles(d.articles));
}, []);
return (
<div>
{articles.map(article => (
<div key={article.id}>{article.title}</div>
))}
</div>
);
}
メリット:
デメリット:
| シナリオ | 推奨 | 理由 |
|---|---|---|
| フォーム送信 (専用) | Server Actions | RPC-like で十分 |
| 複数 source からアクセス | API Routes | RESTful で汎用 |
| 複雑な API | API Routes | キャッシング戦略拡張可 |
| キャッシング不要 | Server Actions | シンプル |
| CDN キャッシュしたい | API Routes | ISR 対応 |
| Mobile App も使う | API Routes | 共通 API |
User (Browser)
↓
Server Component (app/articles/page.tsx)
└─ getArticles() (Server)
└─ Supabase
↓
HTML を Brand (no JS)
メリット:
デメリット:
// ✅ 例: 記事一覧表示のみ
export default async function ArticlesPage() {
const articles = await getArticles();
return (
<div>
{articles.map(article => (
<div key={article.id}>{article.title}</div>
))}
</div>
);
}
User (Browser)
↓
Client Component (form)
└─ useTransition
↓
Server Action (lib/actions.ts)
└─ Supabase
↓
revalidatePath()
↓
UI Update
メリット:
デメリット:
【lib/actions.ts】
'use server';
export async function createArticle(formData: FormData) {
const title = formData.get('title') as string;
await supabase.from('articles').insert({ title });
revalidatePath('/articles'); // キャッシュ無効化
}
【app/articles/new/page.tsx】
'use client';
import { useTransition } from 'react';
import { createArticle } from '@/lib/actions';
export default function NewArticlePage() {
const [isPending, startTransition] = useTransition();
return (
<form action={(fd) => startTransition(() => createArticle(fd))}>
<input name="title" />
<button disabled={isPending}>保存</button>
</form>
);
}
User (Browser)
↓
Client Component
└─ fetch('/api/articles')
└─ API Route
└─ Supabase
↓
setArticles()
↓
UI Update
メリット:
デメリット:
【app/articles/page.tsx】
import { cache } from 'react';
// ✅ リクエスト内でキャッシュ
const getCachedArticles = cache(async () => {
return await supabase.from('articles').select('*');
});
export default async function ArticlesPage() {
// 同じリクエスト内で2回呼ぶと1度だけ DB Hit
const articles1 = await getCachedArticles();
const articles2 = await getCachedArticles(); // キャッシュから取得
return <div>{articles1.length} articles</div>;
}
【app/articles/page.tsx】
export const revalidate = 3600; // 60 分ごとに再生成
export default async function ArticlesPage() {
const articles = await supabase.from('articles').select('*');
return (
<div>
{articles.map(article => (
<div key={article.id}>{article.title}</div>
))}
</div>
);
}
【lib/actions.ts】
'use server';
import { revalidatePath } from 'next/cache';
import { supabase } from '@/lib/supabase';
export async function createArticle(formData: FormData) {
const title = formData.get('title') as string;
await supabase.from('articles').insert({ title });
// ✅ キャッシュを即無効化
revalidatePath('/articles');
revalidatePath('/');
}
【app/articles/page.tsx】
import { cookies } from 'next/headers';
export default async function ArticlesPage() {
// Cookie を読む = Dynamic になる
const sessionId = (await cookies()).get('session_id');
// 毎回 Server Component が実行される
const articles = await supabase.from('articles').select('*');
return <div>{articles.length} articles</div>;
}
// ❌ NG: Client で直接 fetch
'use client';
export function ArticleList() {
const [articles, setArticles] = useState([]);
useEffect(() => {
// Client で DB に接続
fetch('/api/articles')
.then(r => r.json())
.then(setArticles);
}, []);
}
// ✅ OK: Server Component で fetch
export default async function ArticlesPage() {
const articles = await getArticles(); // Server で DB アクセス
return <ClientComponent articles={articles} />;
}
// ❌ NG
export const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL;
export const SUPABASE_KEY = process.env.NEXT_PUBLIC_SUPABASE_KEY;
// ✅ OK
// Server Component でのみ
const supabase = createClient(
process.env.SUPABASE_URL, // NEXT_PUBLIC_ なし
process.env.SUPABASE_KEY
);
// ❌ NG: 大きな Client Component
'use client';
export default function Page() {
const [data, setData] = useState(null);
// 全部が client に送信される
const layout = <Layout />;
const sidebar = <Sidebar />;
const content = <Content />;
return <div>{layout}{sidebar}{content}</div>;
}
// ✅ OK: 分割
export default async function Page() {
return (
<div>
<Layout /> {/* Server Component */}
<Sidebar /> {/* Server Component */}
<Counter /> {/* Client Component: 小さい */}
</div>
);
}
'use client';
function Counter() {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}
// ✅ static export: 完全静的
export const dynamic = 'force-static';
// ✅ ISR: 定期再生成
export const revalidate = 3600; // 60 分
// ✅ Dynamic: 毎回生成
export const dynamic = 'force-dynamic';
export default async function Page() {
// ...
}
export default async function ArticlesPage() {
try {
const articles = await getArticles();
return <ArticleList articles={articles} />;
} catch (error) {
return <Error message="記事取得失敗" />;
}
}
// app/articles/loading.tsx
export default function Loading() {
return <div>読み込み中...</div>;
}
// 自動的に Page rendering 中に表示される
| 選択肢 | 用途 | パフォーマンス | 複雑度 |
|---|---|---|---|
| Server Component のみ | 表示のみ | ⭐⭐⭐⭐⭐ | ⭐ |
| Server Component + Server Actions | フォーム | ⭐⭐⭐⭐ | ⭐⭐ |
| API Routes + useEffect | Mobile API | ⭐⭐⭐ | ⭐⭐⭐ |
推奨: Server Component をデフォルトに、必要に応じて Client Component を使用