Next.js App Router:Server Actionsで「API Routes」という遺産を捨て去る覚悟はあるか
「まだAPI Routesを書いているのか?」
最近、コードレビューでこの言葉を飲み込むのに必死だ。Next.jsのApp Routerが標準となって久しい今、クライアントから`fetch`を叩き、バックエンドでエンドポイントを定義し、型定義を二重管理する。この「泥臭い儀式」を続けている現状に、強烈な違和感を覚えないだろうか。
Server Actionsは、単なる便利機能ではない。HTTP層を抽象化し、フロントエンドとバックエンドの境界を溶かすための「再設計」だ。だが、この強力な武器を使いこなせず、かえって脆弱性と技術的負債を積み上げているプロジェクトも少なくない。
本稿では、Server Actionsを実戦で使い倒すために避けては通れない、深淵な設計思想と「プロの境界線」を共有する。
1. 「型安全」の境界線を再定義する
Server Actionsの最大の恩恵は、クライアントとサーバー間での「型共有」にある。しかし、多くの現場では`any`が蔓延し、せっかくのTypeScriptが泣いている。
ポイントは、バリデーションロジックをプロジェクトの最深部(Domain層)に配置することだ。`@/lib/validations`にスキーマを切り出し、それをインポートする構成を徹底する。
// src/lib/validations/user.ts
import { z } from 'zod';
export const UpdateUsernameSchema = z.object({
username: z.string().min(3).max(20),
});
// src/app/actions/user.ts
"use server";
import { UpdateUsernameSchema } from "@/lib/validations/user";
import { auth } from "@/auth";
export async function updateUsername(prevState: any, formData: FormData) {
const session = await auth();
if (!session) return { error: "Unauthorized" };
const parsed = UpdateUsernameSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) return { error: "Invalid input" };
// ここでトランザクション管理を行う
try {
await db.$transaction([
db.user.update({ ... }),
db.auditLog.create({ ... })
]);
return { success: true };
} catch {
return { error: "Database error" };
}
}
ここで重要なのは、「サーバーサイドの関数は常に疑い深くあれ」という原則だ。認証(`auth`)と型検証(`safeParse`)を欠いたServer Actionは、インターネットに直結された野ざらしのDBクエリに過ぎない。
2. `revalidatePath` の罠とPPRの未来
キャッシュ戦略は、Server Actionsで最もハマりやすい落とし穴だ。`revalidatePath('/')`を安易に呼ぶと、サイト全体の静的生成ページが再構築され、高トラフィック時にRDBへのクエリが雪崩を起こす。
#### 避けるべき設計
- 更新のたびに広範囲のパスを再検証する。
- キャッシュタグの粒度が粗く、無関係なコンポーネントまで再レンダリングされる。
#### 推奨されるアプローチ
Next.js 15で導入された Partial Prerendering (PPR) を視野に入れ、必要な箇所だけを動的にレンダリングする設計へ移行すべきだ。また、`revalidateTag`を活用して、特定のデータソースに紐付いたキャッシュのみをピンポイントで無効化する。
3. モダンなUI連携:`useActionState` の活用
エラーハンドリングを「投げっぱなし」にするのは素人の仕事だ。モダンなNext.jsでは、`useActionState`(旧`useFormState`)を用いて、サーバーの状態をUIに同期させるのが正攻法である。
// src/components/username-form.tsx
"use client";
import { useActionState } from "react";
import { updateUsername } from "@/app/actions/user";
export function UsernameForm() {
const [state, action, isPending] = useActionState(updateUsername, null);
return (
<form action={action}>
<input name="username" />
{state?.error && <p className="text-red-500">{state.error}</p>}
<button disabled={isPending}>
{isPending ? "更新中..." : "保存"}
</button>
</form>
);
}
この実装の美しさは、クライアントサイドで複雑な`fetch`のステート管理(loading, error, data)を一切書かなくて済む点にある。フレームワークに状態管理を委ねることで、ビジネスロジックに集中できるのだ。
4. シリアライズの壁とデータ構造
Server Actionsで受け渡しができるデータ型には制限がある(JSONシリアライズ可能である必要がある)。`Date`オブジェクトや複雑なクラスインスタンスをそのまま渡すと、サーバー側で予期せぬ挙動を引き起こす。
- 原則: プリミティブな値、あるいはシリアライズ可能なプレーンなオブジェクトのみを引数に取る。
- 回避策: 複雑なデータが必要な場合は、IDのみを渡し、サーバー側で再取得(Re-fetch)する設計が最も堅牢だ。
最後に:ブレーキの踏み方を知る者だけが加速できる
Server Actionsはフルスタック開発の体験を劇的に変えた。しかし、それは「魔法」ではなく、単なる「関数呼び出しのHTTP抽象化」に過ぎない。
- 認証と権限チェックは関数内で完結させること。
- キャッシュの再検証は最小単位に絞ること。
- UI連携は`useActionState`で標準化すること。
コードは嘘をつかない。書いた通りに動く。その「動き」が脆弱性にならないよう、我々エンジニアは常にコードの深層に目を凝らす必要がある。