はじめに
Server Actionsは、「APIを作らずにサーバー関数を呼べる機能」と説明されることがあります。
自分もそのくらいの粒度で使っていました。
この説明でも動くものは作れますが、'use client' をどこに置くか、更新後に画面がなぜ変わるのか、といったところで毎回手が止まります。
Next.js App Routerでは、画面が主に次の役割分担で構成されます。
React Server Components
サーバー状態を読み取り、UIを生成する
Client Components
ユーザー操作とブラウザ上の状態を扱う
Server Actions
ユーザー操作をサーバー状態へ反映する
RSCの再実行
更新後のサーバー状態からUIを生成し直す
この配置で見ると、Server Actionsは単独の便利機能ではありません。
RSCで構築したサーバー主導のUIに、更新経路を1本足す仕組みです。
そこで、SSR、RSC、Client Components、Server Functions、React 19のAction系Hooksを、1つの処理フローとして整理し直しました。
その記録が本記事です。
環境は React 19 / Next.js App Router(React 19 に対応した Next.js 15 以降)/ TypeScript を前提にしています。
キャッシュ挙動や Progressive Enhancement の範囲はバージョンによって差があるので、最終的な確認はそのつど利用中のバージョンの公式ドキュメントで行います。
掲載するコードは仕組みの説明を目的とした最小例で、実務ではエラー処理や監査ログを別途足す必要があります。
参照した公式ドキュメントは記事末に列挙しました。
1. 最初に4つの概念を分離する
RSC周辺が分かりにくい理由は、異なるレイヤーの概念が同時に説明されるためです。
| 概念 | 答える問い |
|---|---|
| MPA / SPA | ページ遷移をどのように行うか |
| SSR / CSR | 初期HTMLをどこで生成するか |
| Server / Client Components | Reactコンポーネントをどこで実行するか |
| Server Actions / Route Handlers | ブラウザからサーバー処理をどう呼び出すか |
これらは単純な置き換え関係ではありません。
Next.js App Routerの1ページは、同時に次の性質を持てます。
初回アクセス
→ サーバーでHTMLを生成して早く表示する
コンポーネント構成
→ Server ComponentとClient Componentを組み合わせる
ページ内遷移
→ SPAのようにフルリロードせず移動する
データ更新
→ Server Actionを呼び出す
更新後
→ 新しいRSC Payloadを既存のReactツリーへ反映する
2. SSRとRSCは何が違うのか
SSRの目的
SSRは、初回表示用HTMLをサーバーで生成する仕組みです。
Reactコンポーネント
↓ サーバーでレンダリング
HTML
↓ ブラウザへ送信
画面表示
↓ JavaScript読み込み
Hydration
従来型のSSRでは、サーバーでHTMLを生成しても、ブラウザ上で同じReactコンポーネントを動作させるため、対応するJavaScriptが必要になります。
RSCの目的
React Server Componentsは、コンポーネント自体をサーバーで実行し、そのコンポーネントのJavaScriptをブラウザへ送らない仕組みです。
// Server Component
import { db } from '@/lib/db'
export default async function ProductList() {
const products = await db.product.findMany()
return (
<ul>
{products.map((product) => (
<li key={product.id}>{product.name}</li>
))}
</ul>
)
}
このコンポーネントについて、ブラウザへ送る必要がないものは次のとおりです。
- DBアクセスコード
- DBドライバー
- サーバー専用ライブラリ
- ProductList関数の実装
- サーバー側だけで使用する依存パッケージ
ブラウザには、サーバーで実行した結果と、Client Componentとの接続情報が送られます。
違いを一文で表す
SSR
→ HTMLをどこで生成するか
RSC
→ コンポーネントをどこで実行し、どのJavaScriptを配信しないか
SSRとRSCは競合するものではなく、Next.jsでは初回表示時に組み合わせて使われます。
3. RSC Payloadとは何か
Server Componentは、直接ブラウザ用JavaScriptになるわけではありません。
Next.jsはServer Componentの実行結果を、RSC Payloadと呼ばれるReact用のデータ表現へ変換します。
概念的には次の情報が含まれます。
Server Componentのレンダリング結果
Client Componentを挿入する場所
Client Componentのモジュール参照
Server Componentから渡すProps
Suspense境界
Reactツリーを再構成するための情報
初回アクセス時の大まかな流れは次のとおりです。
テキスト版を開く
1. ブラウザがページを要求する
2. Server Components をサーバーで実行する
3. RSC Payload を生成する
4. RSC Payload と Client Components から HTML を生成する
5. HTML をブラウザへ送信する
6. ブラウザが HTML を表示する
7. Client Component 用 JavaScript を読み込む
8. Client Components を Hydration して操作可能にする
4. Client Componentはブラウザだけで描画されるわけではない
次のコンポーネントはClient Componentです。
'use client'
import { useState } from 'react'
export function Counter() {
const [count, setCount] = useState(0)
return (
<button onClick={() => setCount((value) => value + 1)}>
{count}
</button>
)
}
Client Componentである理由は、ブラウザ上で次を扱うためです。
useStateonClick- ユーザー操作後も維持する状態
ただし、初回表示ではClient Componentもサーバー側でHTMLへ事前レンダリングされる場合があります。
その後、ブラウザにJavaScriptが届き、Hydrationされることで操作可能になります。
Client Component
初回HTML生成: サーバーでも処理され得る
イベント処理: ブラウザで実行
状態管理: ブラウザで実行
JavaScript: ブラウザへ送られる
Hydration: 必要
Server Componentは次のとおりです。
Server Component
コンポーネント実行: サーバーのみ
JavaScript: ブラウザへ送られない
Hydration: 不要
useState / onClick: 使用できない
5. 'use client'は依存ツリーの境界を作る
'use client'は、関数単体に「ブラウザで実行する」という印を付けるだけではありません。
そのファイルからimportされる依存関係も、クライアントバンドルへ含まれる可能性があります。
ClientComponent.tsx
├── Button.tsx
├── formatter.ts
└── それらがimportする依存関係
そのため、ページ全体へ安易に'use client'を付けると、表示専用コードや大きなライブラリまでブラウザへ送られます。
推奨する分割
// Server Component
import { FavoriteButton } from './FavoriteButton'
import { getProduct } from '@/features/products/query'
export default async function ProductPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
const product = await getProduct(id)
return (
<article>
<h1>{product.name}</h1>
<p>{product.description}</p>
{/* 操作が必要な末端だけClient Component */}
<FavoriteButton productId={product.id} />
</article>
)
}
// FavoriteButton.tsx
'use client'
import { useState } from 'react'
export function FavoriteButton({
productId,
}: {
productId: string
}) {
const [selected, setSelected] = useState(false)
return (
<button
type="button"
onClick={() => setSelected((value) => !value)}
aria-pressed={selected}
>
{selected ? 'お気に入り済み' : 'お気に入り'}
</button>
)
}
基本方針は次のとおりです。
可能な限りServer Componentにし、ブラウザ状態やイベントが必要な末端だけClient Componentにする。
6. Server ComponentからClient Componentへ渡せる値
Server ComponentとClient Componentの間にはネットワーク境界があります。
そのため、PropsはReactがシリアライズできる値である必要があります。
React 公式が挙げているのは次の値です。
- string / number / bigint / boolean / undefined / null
-
Symbol.for()でグローバル登録された Symbol - シリアライズ可能な値を含む Iterable(String / Array / Map / Set / TypedArray / ArrayBuffer)
- Date
- シリアライズ可能なプロパティを持つプレーンオブジェクト
- Server Function
- Client / Server Component の React 要素(JSX)
- Promise
FormData は Server Function の引数としては使えますが、Client Component へ渡す Props の一覧には入っていません。
引数のシリアライズ規則と Props のシリアライズ規則は別ものです。
通常は渡せないものは次のとおりです。
- 通常の関数
- DB接続オブジェクト
- DOMノード
- シリアライズできないクラスインスタンス
- サーバー専用ライブラリのインスタンス
次のようなPropsは渡せません。
// Server Component
<ClientComponent
onSave={() => {
console.log('save')
}}
/>
通常の関数をClient Componentへ渡したい場合は、Client Component側で定義します。
サーバー処理を渡したい場合は、'use server'でServer Functionとして公開します。
7. Server FunctionとServer Action
Reactの現在の用語では、'use server'で公開された非同期関数をServer Functionと呼びます。
// app/reservations/actions.ts
'use server'
export async function createReservation(formData: FormData) {
// サーバーで実行
}
Client Componentからimportすると、関数本体がブラウザへ送られるわけではありません。
ビルド時にServer Functionへの参照へ変換されます。
Client Component
↓ Server Function参照を呼ぶ
React / Next.jsがリクエストを生成
↓
サーバーで関数を実行
↓
シリアライズ可能な戻り値を返す
コード上は関数呼び出しですが、実体はネットワーク通信です。
await createReservation(formData)
したがって、Server Functionは次のように扱う必要があります。
外部入力を受け取るエンドポイント
8. RSCとServer Actionsがセットになりやすい理由
RSCだけでは、主にサーバーからブラウザへの読み取り方向を担当します。
Webアプリには逆方向も必要で、そこを埋めるのが Server Actions です。
テキスト版を開く
読み取り方向
Database → Server Component → RSC Payload → Browser
更新方向
Browser(ユーザー操作) → Server Action → Database 更新
→ 再検証して Server Component を再実行
→ 新しい RSC Payload → Browser へ反映
この循環が、RSCとServer Actionsを組み合わせる基本モデルです。
9. Server Action実行時に何が起きるか
次のフォームを考えます。
<form action={createReservationAction}>
<input name="customerName" />
<input name="date" type="date" />
<button type="submit">予約する</button>
</form>
送信時には、概念的に次の処理が行われます。
ここで注意したいのは、6〜8がNext.jsの自動処理ではないことです。
認証・認可・DB更新・再検証は、いずれもAction内に自分で書く処理です。
9以降も、表示中のパスを再検証した場合に起きるもので、再検証しなければ画面は変わりません。
テキスト版を開く
1. ユーザーがフォームを送信する
2. ブラウザから Next.js へリクエストが飛ぶ
3. 対象の Server Function を特定する
4. FormData や引数を復元する
5. Server Action を実行する
6. 認証・認可・入力検証を行う(Action 内に自分で書く)
7. データベースを更新する(Action 内に自分で書く)
8. 必要ならキャッシュを再検証する(Action 内に自分で書く)
9. 表示中のパスを再検証したなら Server Components を再実行する
10. 新しい RSC Payload を生成する
11. Action の結果と、あれば RSC Payload を返す
12. React が既存ツリーへ反映する
ここが通常のREST APIとの大きな違いです。
APIはJSONを返すことが主目的ですが、Server Actionは更新後のReactツリーを再構成する処理と統合できます。
「必ず統合される」ではなく「同じ往復で返せる」という能力の話です。
10. RSC + Server Actionの実装例
予約一覧と予約登録フォームを実装します。
ディレクトリ構成
app/
└── reservations/
├── page.tsx
├── ReservationForm.tsx
└── actions.ts
features/
└── reservations/
├── command.ts
└── query.ts
表示用Query
// features/reservations/query.ts
import { db } from '@/lib/db'
export async function listReservations() {
return db.reservation.findMany({
orderBy: {
date: 'asc',
},
})
}
更新用Service
// features/reservations/command.ts
import { db } from '@/lib/db'
type ReserveInput = {
operatorId: string
customerName: string
date: Date
}
export async function reserve(input: ReserveInput) {
const duplicated = await db.reservation.findFirst({
where: {
customerName: input.customerName,
date: input.date,
},
})
if (duplicated) {
return {
ok: false as const,
reason: 'DUPLICATED' as const,
}
}
const reservation = await db.reservation.create({
data: {
customerName: input.customerName,
date: input.date,
createdBy: input.operatorId,
},
})
return {
ok: true as const,
reservation,
}
}
Server Action
// app/reservations/actions.ts
'use server'
import { revalidatePath } from 'next/cache'
import { requireSession } from '@/lib/auth'
import { reserve } from '@/features/reservations/command'
export type ReservationActionState = {
success: boolean
message: string
errors?: {
customerName?: string[]
date?: string[]
}
}
export async function createReservationAction(
_previousState: ReservationActionState,
formData: FormData,
): Promise<ReservationActionState> {
const session = await requireSession()
const rawCustomerName = formData.get('customerName')
const rawDate = formData.get('date')
const errors: ReservationActionState['errors'] = {}
if (
typeof rawCustomerName !== 'string' ||
rawCustomerName.trim() === ''
) {
errors.customerName = ['氏名を入力してください']
}
if (typeof rawDate !== 'string' || rawDate === '') {
errors.date = ['利用日を入力してください']
}
if (Object.keys(errors).length > 0) {
return {
success: false,
message: '入力内容を確認してください',
errors,
}
}
const date = new Date(rawDate)
if (Number.isNaN(date.getTime())) {
return {
success: false,
message: '入力内容を確認してください',
errors: {
date: ['正しい日付を入力してください'],
},
}
}
const result = await reserve({
operatorId: session.userId,
customerName: rawCustomerName.trim(),
date,
})
if (!result.ok) {
return {
success: false,
message: '同じ内容の予約が登録されています',
}
}
revalidatePath('/reservations')
return {
success: true,
message: '予約を登録しました',
}
}
Server Component
// app/reservations/page.tsx
import { listReservations } from '@/features/reservations/query'
import { ReservationForm } from './ReservationForm'
export default async function ReservationsPage() {
const reservations = await listReservations()
return (
<main>
<h1>予約一覧</h1>
<ReservationForm />
<ul>
{reservations.map((reservation) => (
<li key={reservation.id}>
{reservation.customerName}
{' / '}
{reservation.date.toLocaleDateString('ja-JP')}
</li>
))}
</ul>
</main>
)
}
Client Component
// app/reservations/ReservationForm.tsx
'use client'
import { useActionState } from 'react'
import { useFormStatus } from 'react-dom'
import {
createReservationAction,
type ReservationActionState,
} from './actions'
const initialState: ReservationActionState = {
success: false,
message: '',
}
function SubmitButton() {
const { pending } = useFormStatus()
return (
<button type="submit" disabled={pending}>
{pending ? '登録中...' : '予約する'}
</button>
)
}
export function ReservationForm() {
const [state, formAction] = useActionState(
createReservationAction,
initialState,
)
return (
<form action={formAction}>
<div>
<label htmlFor="customerName">氏名</label>
<input id="customerName" name="customerName" />
{state.errors?.customerName?.map((message) => (
<p key={message}>{message}</p>
))}
</div>
<div>
<label htmlFor="date">利用日</label>
<input id="date" name="date" type="date" />
{state.errors?.date?.map((message) => (
<p key={message}>{message}</p>
))}
</div>
<SubmitButton />
{state.message && (
<p aria-live="polite">{state.message}</p>
)}
</form>
)
}
11. このコードの初回表示
ReservationsPageをサーバーで実行
↓
listReservationsでDBから取得
↓
Server Component部分をRSC Payloadへ変換
↓
ReservationFormはClient Component参照として組み込む
↓
初期HTMLを生成
↓
ブラウザへ送信
↓
HTMLを表示
↓
ReservationFormのJavaScriptを読み込む
↓
Hydrationして操作可能になる
12. フォーム送信後の流れ
ReservationFormからActionを実行
↓
createReservationActionがサーバーで動作
↓
認証・入力検証
↓
reserveでDB更新
↓
revalidatePath('/reservations')
↓
ReservationsPageを再実行
↓
最新の予約一覧を取得
↓
新しいRSC Payloadを返す
↓
Reactが既存の画面へマージ
ブラウザ側で明示的に一覧APIを再取得して、配列へ追加するコードを書かなくても、Server Componentを最新状態から再生成できます。
13. useActionStateの役割
useActionStateは、一般的なuseStateの代わりではありません。
Actionに関係する次の状態を扱うためのHookです。
- Actionの最後の戻り値
- 送信中状態
- 前回の状態を受け取る処理
- Hydration前のフォーム応答
- Progressive Enhancement
基本形は次のとおりです。
const [state, formAction, isPending] = useActionState(
actionFunction,
initialState,
)
Action関数のシグネチャは次のとおりです。
async function actionFunction(
previousState: State,
payload: FormData,
): Promise<State> {
// ...
}
フォームと組み合わせた場合、2番目の引数は通常FormDataです。
初回
state = initialState
Action実行
previousState = 現在のstate
Action完了
state = Actionの戻り値
useStateが引き続き必要な例
- モーダルの開閉
- 選択中のタブ
- 入力途中のローカル値
- ブラウザ内だけで完結する表示状態
- ドラッグ中の状態
useActionStateは、Actionの実行結果をUIへ接続するための専用Hookです。
14. useFormStatusの役割
useFormStatusは、親の<form>が送信中かどうかを子コンポーネントから取得します。
function SubmitButton() {
const { pending, data, method, action } = useFormStatus()
return (
<button type="submit" disabled={pending}>
{pending ? '送信中...' : '送信'}
</button>
)
}
重要な制約があります。
useFormStatusを呼ぶコンポーネントは、対象フォームの内側に配置する必要があります。
次は誤った例です。
function Form() {
// このコンポーネント自身が返すformは親ではない
const { pending } = useFormStatus()
return (
<form action={submitAction}>
<button disabled={pending}>送信</button>
</form>
)
}
次が正しい例です。
function SubmitButton() {
const { pending } = useFormStatus()
return <button disabled={pending}>送信</button>
}
function Form() {
return (
<form action={submitAction}>
<SubmitButton />
</form>
)
}
15. useOptimisticの役割
Server Actionの完了を待ってから画面を変えると、ネットワーク往復分の遅延があります。
クリック
↓
Server Action
↓
DB更新
↓
レスポンス
↓
画面更新
useOptimisticを使うと、サーバー応答前に一時的な表示を作れます。
クリック
↓
画面を先に更新
↓
Server Action
↓
成功なら正式な状態へ収束
失敗なら元の状態へ戻す、またはエラー表示
次に例を示します。
'use client'
import { useOptimistic, startTransition } from 'react'
import { toggleFavoriteAction } from './actions'
type Props = {
productId: string
favorite: boolean
}
export function FavoriteButton({
productId,
favorite,
}: Props) {
const [optimisticFavorite, setOptimisticFavorite] =
useOptimistic(favorite)
function handleClick() {
startTransition(async () => {
setOptimisticFavorite(!optimisticFavorite)
await toggleFavoriteAction(productId)
})
}
return (
<button type="button" onClick={handleClick}>
{optimisticFavorite ? 'お気に入り済み' : 'お気に入り'}
</button>
)
}
楽観的更新が適しているのは、失敗率が低く、操作の取り消しやエラー表示が可能な処理です。
次の処理では慎重に使います。
- 決済確定
- 在庫の最終確保
- 法的な承認
- 取り消し不能な処理
- セキュリティ設定変更
16. Progressive Enhancement
<form action={serverFunction}> は、JavaScript の読み込み完了前でもフォーム送信を成立させやすい構造です。
ただし「成立する」と「キューに入る」は別の挙動で、フォームをどこに置くかで変わります。
ここを1つにまとめて書いていたのが自分の理解の粗さでした。
Server Component 内のフォーム
→ Server Action を <form action> へ直接渡した場合、
JavaScript 未読込・無効時でも通常のフォーム送信として動作する
Client Component 内のフォーム
→ Hydration 前の送信はキューに入り、Hydration 後に実行される
startTransition() 経由のイベントハンドラ呼び出し
→ フォームの Progressive Enhancement は働かない
useActionState の permalink
→ JavaScript 読込前の送信時に指定 URL へ移動する。
遷移先でも同じ Action と permalink を持つフォームを描画する必要がある
Progressive Enhancementを重視する場合は、実際の対象ブラウザとデプロイ環境で確認します。
17. Server Actionへ追加引数を渡す
フォームに含まれない値を渡す場合は、bind()を利用できます。
import { updateUserAction } from './actions'
export function UserForm({
userId,
}: {
userId: string
}) {
const updateUser = updateUserAction.bind(null, userId)
return (
<form action={updateUser}>
<input name="name" />
<button type="submit">更新</button>
</form>
)
}
Action側の実装は次のとおりです。
'use server'
export async function updateUserAction(
userId: string,
formData: FormData,
) {
// ...
}
ただし、userIdはクライアントから変更可能な入力値として扱います。
const session = await requireSession()
const user = await db.user.findUnique({ where: { id: userId } })
if (!user || user.tenantId !== session.tenantId) {
throw new Error('Forbidden')
}
bind()で渡したから安全、hidden inputだから安全、ということはありません。
18. エラーの扱い
想定されるエラー
入力ミスや業務ルール違反は、ActionStateとして返します。
return {
success: false,
message: '指定期間は予約済みです',
}
想定外のエラー
プログラム不具合、DB障害、外部サービス障害などはthrowします。
throw new Error('Payment provider unavailable')
ReactはError BoundaryやNext.jsのerror.tsxへ処理を移せます。
基本方針は次のとおりです。
ユーザーが修正できるエラー
→ 戻り値
システム障害・想定外エラー
→ throw
ただし、機密情報をエラーメッセージへ含めないようにします。
19. キャッシュ再検証を理解する
DBを更新しただけでは、現在表示しているServer Componentが自動的に最新データを取得するとは限りません。
更新後に、キャッシュ戦略に対応した再検証を行います。
revalidatePath('/reservations')
考え方は次のとおりです。
DBの状態
→ 更新済み
Next.jsのキャッシュ
→ 古い可能性がある
現在のRSC表示
→ 再生成が必要
再検証の手段は、キャッシュの作り方に合わせて選びます。
- パス単位で再検証する(
revalidatePath()) - データタグ単位で再検証する(
revalidateTag()) - そもそもキャッシュしないデータ取得として設計する
router.refresh() はこの一覧に含めていません。
現在のルートを再取得して表示を更新する操作であって、サーバー側のキャッシュを無効化するものではないためです。
表示だけ更新しても、キャッシュが古いままなら同じ内容が返ってきます。
重要なのは、DB更新とUI更新を別々に考えることです。
DBを更新した
≠
ブラウザの表示が必ず更新された
20. Server Actionsのセキュリティ
Server Actionは内部関数ではありません。
Client Componentから呼ばれるServer Functionは、ネットワーク経由で実行されます。
必ず次を検証します。
認証
誰が操作しているか
認可
そのユーザーが操作を実行できるか
所有権・テナント境界
対象データが本人または所属組織のものか
入力検証
型、長さ、形式、範囲、列挙値が正しいか
業務状態
現在の状態からその操作が許されるか
次に例を示します。
'use server'
export async function cancelReservationAction(
reservationId: string,
) {
const session = await requireSession()
const reservation = await db.reservation.findUnique({
where: {
id: reservationId,
},
})
if (!reservation) {
return {
success: false,
message: '予約が存在しません',
}
}
if (
reservation.userId !== session.userId &&
session.role !== 'ADMIN'
) {
throw new Error('Forbidden')
}
if (reservation.status !== 'CONFIRMED') {
return {
success: false,
message: 'この予約はキャンセルできません',
}
}
await db.reservation.update({
where: {
id: reservationId,
},
data: {
status: 'CANCELLED',
},
})
revalidatePath('/reservations')
return {
success: true,
message: 'キャンセルしました',
}
}
画面にボタンを表示しないことはUX上の制御であり、認可ではありません。
21. Server Actionsをデータ取得に使わない
ReactのServer Functionsは、サーバー状態の更新を主目的としています。
次の用途には向きません。
- 一覧の通常取得
- 検索サジェスト
- 高頻度ポーリング
- 無限スクロール
- 並列データ取得
- クライアントキャッシュのQuery Function
- IoTのテレメトリー取得
Server Componentから取得できる場合は次の経路になります。
Server Component
→ Query / Service
→ DB
ブラウザから取得する必要がある場合は次の経路になります。
Client Component
→ Route Handler
→ Query / Service
→ DB
Server Functionsは通常のGET APIの代替ではありません。
22. Server ComponentとClient Componentの合成
Server ComponentからClient Componentを使う
通常の構成です。
// Server Component
import { InteractiveButton } from './InteractiveButton'
export default async function Page() {
const data = await getData()
return (
<InteractiveButton initialValue={data.value} />
)
}
Client ComponentからServer Componentを直接importしない
'use client'
// 基本的に避ける
import { ServerProductList } from './ServerProductList'
Client Componentの依存ツリーはクライアント向けに扱われるため、サーバー専用処理を直接含められません。
Server Componentをchildrenとして渡す
これは可能です。
// Server Component
import { Modal } from './Modal'
import { ProductDetail } from './ProductDetail'
export default function Page() {
return (
<Modal>
<ProductDetail />
</Modal>
)
}
// Client Component
'use client'
export function Modal({
children,
}: {
children: React.ReactNode
}) {
return (
<div role="dialog">
{children}
</div>
)
}
ProductDetailはサーバー側で実行され、その結果がReact要素としてchildrenへ渡されます。
23. Server ActionsとRoute Handlersの関係
Server Actionsを採用しても、Route Handlerが不要になるわけではありません。
Server Action
Next.js UIからの更新
Route Handler
外部API
Webhook
OAuth callback
ファイル配信
SSE / Streaming
CORSが必要なAPI
モバイルアプリ向けAPI
両方から同じServiceを呼ぶ構成が実務的です。
Server Action ─┐
├→ Application Service → Repository → DB
Route Handler ─┘
Server ActionsとAPI方式は競合関係ではなく、異なる境界を担当します。
24. Server Actionsの注意点
1. 呼び出しは非同期
Server Functionはネットワーク通信なので、必ず非同期です。
2. 引数と戻り値に制約がある
Reactがシリアライズできる値だけを利用します。
3. 高頻度・並列取得には向かない
更新処理向けであり、取得APIとして利用しません。
4. JavaScriptバンドルを意識する
'use client'の位置が広すぎると、不要なJavaScriptがブラウザへ送られます。
5. 再検証戦略が必要
Action完了後に、どのキャッシュと画面を更新するかを設計します。
6. 認証済みページでもAction内の認可が必要
ページレベルのチェックだけでは不十分です。
7. Service層へロジックを分離する
Actionを巨大な業務ロジックの置き場所にしないようにします。
25. 実装時チェックリスト
コンポーネント境界
-
Server Componentで実装できる部分へ
'use client'を付けていない - Client Componentを操作が必要な末端へ限定した
- Client Componentへ渡すPropsがシリアライズ可能
- Client Componentからサーバー専用モジュールをimportしていない
Server Action
-
Actionは
async関数 - 引数を外部入力として検証している
- Action内部で認証・認可を実施している
- 所有権またはテナント境界を確認している
- 想定内エラーは戻り値として表現している
- 想定外エラーはError Boundaryへ渡している
- 更新後の再検証方法を決めている
- 業務ロジックをService層へ分離している
React 19 Hooks
-
Action結果の表示には
useActionStateを検討した -
送信ボタンのPending表示には
useFormStatusを検討した -
useFormStatusを対象フォームの子で呼び出している -
楽観的更新が安全な処理だけ
useOptimisticを利用している -
useActionStateを通常のuseStateの代替として誤用していない
APIとの使い分け
- 外部クライアントから呼ばれる処理はRoute Handlerにした
- WebhookをServer Actionで受けようとしていない
- ファイル・ストリーム・SSEはRoute Handlerを検討した
- Server Componentから自分自身のAPIを呼んでいない
- 高頻度のデータ取得にServer Actionを使っていない
26. まとめ
Server Actionsの本質を、単なる「API省略機能」として捉えると、使いどころを誤りやすくなります。
RSCとの関係を含めると、役割は次のように整理できます。
Server Component
サーバー状態を読み取ってUIを生成
Client Component
ユーザー操作とブラウザ状態を管理
Server Action
ユーザー操作をサーバー状態へ反映
RSC Payload
更新後のサーバーUIをブラウザへ伝達
React
既存ツリーと新しいRSC Payloadを統合
最終的には、次の循環として理解すると分かりやすくなります。
テキスト版を開く
Database
↓ 読み取り
Server Component
↓ RSC Payload
Browser / Client Component
↓ ユーザー操作
Server Action
↓ 更新
Database
↓ 再検証
Server Component を再実行
Server Actionsは、RSCで生成されたUIに対し、サーバー状態を更新し、更新後のUIをReactツリーへ戻すための仕組みです。
参考資料
- Next.js: Server and Client Components
- Next.js: Server Actions and Mutations
- Next.js: How to create forms with Server Actions
- Next.js: Mutating Data
- Next.js: Data Security
- Next.js: revalidatePath
- Next.js: refresh
- React: Server Components
- React: Server Functions
- React: 'use server'
- React: 'use client'
- React: useActionState
- React: useFormStatus
- React: useOptimistic
- React 19
- RSC・SSR・SPAを2分で理解(monotein)



