Next.js 15のServer Actions完全ガイド — フォーム処理からデータ更新まで
Server Actionsとは
Server Actionsは、Next.js 13.4で実験的に導入され、Next.js 15で正式リリースされた機能です。サーバーサイドのロジックをクライアントから直接呼び出せる、React Server Componentsの重要な機能の一つです。
従来のAPIルートとの違い
従来のAPIルート方式:
// app/api/user/route.ts
export async function POST(request: Request) {
const data = await request.json()
// 処理
}
// クライアント側
const response = await fetch('/api/user', {
method: 'POST',
body: JSON.stringify(data)
})
Server Actions方式:
// app/actions.ts
'use server'
export async function createUser(formData: FormData) {
// 直接サーバー処理
}
// クライアント側
<form action={createUser}>
APIルートを作成する必要がなく、コードがシンプルになります。
基本的な使い方
シンプルなフォーム送信
// app/actions.ts
'use server'
import { db } from '@/lib/db'
import { revalidatePath } from 'next/cache'
export async function createPost(formData: FormData) {
const title = formData.get('title') as string
const content = formData.get('content') as string
await db.post.create({
data: { title, content }
})
revalidatePath('/posts')
}
// app/posts/new/page.tsx
import { createPost } from '@/app/actions'
export default function NewPostPage() {
return (
<form action={createPost}>
<input name="title" required />
<textarea name="content" required />
<button type="submit">投稿</button>
</form>
)
}
Client Componentでの使用
Client ComponentではuseFormStateやuseFormStatusと組み合わせます。
'use client'
import { useFormState, useFormStatus } from 'react-dom'
import { createPost } from '@/app/actions'
function SubmitButton() {
const { pending } = useFormStatus()
return (
<button type="submit" disabled={pending}>
{pending ? '送信中...' : '投稿'}
</button>
)
}
export default function NewPostForm() {
const [state, formAction] = useFormState(createPost, null)
return (
<form action={formAction}>
<input name="title" required />
<textarea name="content" required />
<SubmitButton />
{state?.error && <p className="error">{state.error}</p>}
</form>
)
}
バリデーション
Zodを使った型安全なバリデーション。
// app/actions.ts
'use server'
import { z } from 'zod'
import { db } from '@/lib/db'
const postSchema = z.object({
title: z.string().min(1, 'タイトルは必須です').max(100),
content: z.string().min(10, '本文は10文字以上必要です'),
tags: z.array(z.string()).optional(),
})
export async function createPost(prevState: any, formData: FormData) {
// バリデーション
const validatedFields = postSchema.safeParse({
title: formData.get('title'),
content: formData.get('content'),
tags: formData.getAll('tags'),
})
if (!validatedFields.success) {
return {
errors: validatedFields.error.flatten().fieldErrors,
message: 'バリデーションエラーが発生しました',
}
}
try {
await db.post.create({
data: validatedFields.data,
})
revalidatePath('/posts')
return { message: '投稿を作成しました' }
} catch (error) {
return { message: 'エラーが発生しました' }
}
}
'use client'
export default function PostForm() {
const [state, formAction] = useFormState(createPost, null)
return (
<form action={formAction}>
<div>
<input name="title" />
{state?.errors?.title && (
<p className="error">{state.errors.title[0]}</p>
)}
</div>
<div>
<textarea name="content" />
{state?.errors?.content && (
<p className="error">{state.errors.content[0]}</p>
)}
</div>
<button type="submit">投稿</button>
{state?.message && <p>{state.message}</p>}
</form>
)
}
楽観的UI更新
useOptimisticを使ってUIを即座に更新します。
'use client'
import { useOptimistic } from 'react'
import { addComment } from '@/app/actions'
export default function Comments({ postId, initialComments }: Props) {
const [optimisticComments, addOptimisticComment] = useOptimistic(
initialComments,
(state, newComment: string) => [
...state,
{ id: 'temp', text: newComment, createdAt: new Date() }
]
)
async function handleSubmit(formData: FormData) {
const comment = formData.get('comment') as string
// 楽観的UI更新
addOptimisticComment(comment)
// サーバーに送信
await addComment(postId, comment)
}
return (
<div>
<ul>
{optimisticComments.map((comment) => (
<li key={comment.id} className={comment.id === 'temp' ? 'pending' : ''}>
{comment.text}
</li>
))}
</ul>
<form action={handleSubmit}>
<input name="comment" required />
<button type="submit">コメント</button>
</form>
</div>
)
}
データの再検証
revalidatePath
特定のパスのキャッシュを無効化します。
'use server'
import { revalidatePath } from 'next/cache'
export async function updatePost(id: string, formData: FormData) {
await db.post.update({
where: { id },
data: { /* ... */ }
})
// 特定のページを再検証
revalidatePath('/posts')
revalidatePath(`/posts/${id}`)
}
revalidateTag
タグベースでキャッシュを無効化します。
'use server'
import { revalidateTag } from 'next/cache'
export async function createPost(formData: FormData) {
await db.post.create({ /* ... */ })
// タグで再検証
revalidateTag('posts')
}
// データフェッチ時にタグを設定
export async function getPosts() {
const res = await fetch('https://api.example.com/posts', {
next: { tags: ['posts'] }
})
return res.json()
}
リダイレクト
処理後にリダイレクトする場合。
'use server'
import { redirect } from 'next/navigation'
export async function createPost(formData: FormData) {
const post = await db.post.create({ /* ... */ })
// 作成した投稿ページにリダイレクト
redirect(`/posts/${post.id}`)
}
エラーハンドリング
'use server'
export async function deletePost(postId: string) {
try {
const post = await db.post.findUnique({ where: { id: postId } })
if (!post) {
return { error: '投稿が見つかりません' }
}
await db.post.delete({ where: { id: postId } })
revalidatePath('/posts')
return { success: true }
} catch (error) {
console.error('Delete error:', error)
return { error: '削除に失敗しました' }
}
}
'use client'
export default function DeleteButton({ postId }: { postId: string }) {
const [isPending, startTransition] = useTransition()
async function handleDelete() {
startTransition(async () => {
const result = await deletePost(postId)
if (result.error) {
alert(result.error)
}
})
}
return (
<button onClick={handleDelete} disabled={isPending}>
{isPending ? '削除中...' : '削除'}
</button>
)
}
セキュリティ
認証チェック
'use server'
import { auth } from '@/lib/auth'
import { redirect } from 'next/navigation'
export async function updateProfile(formData: FormData) {
const session = await auth()
if (!session) {
redirect('/login')
}
// ユーザー認証済みの処理
await db.user.update({
where: { id: session.user.id },
data: { /* ... */ }
})
}
権限チェック
'use server'
export async function deletePost(postId: string) {
const session = await auth()
const post = await db.post.findUnique({ where: { id: postId } })
if (!post) {
throw new Error('投稿が見つかりません')
}
if (post.authorId !== session.user.id) {
throw new Error('権限がありません')
}
await db.post.delete({ where: { id: postId } })
}
CSRFトークン(自動処理)
Next.jsのServer Actionsは自動的にCSRF保護されています。追加の実装は不要です。
useTransitionとの組み合わせ
'use client'
import { useTransition } from 'react'
import { updateSettings } from '@/app/actions'
export default function SettingsForm() {
const [isPending, startTransition] = useTransition()
async function handleSubmit(formData: FormData) {
startTransition(async () => {
await updateSettings(formData)
})
}
return (
<form action={handleSubmit}>
<input name="username" />
<button type="submit" disabled={isPending}>
{isPending ? '保存中...' : '保存'}
</button>
</form>
)
}
ファイルアップロード
'use server'
import { put } from '@vercel/blob'
export async function uploadAvatar(formData: FormData) {
const file = formData.get('avatar') as File
if (!file) {
return { error: 'ファイルを選択してください' }
}
const blob = await put(file.name, file, {
access: 'public',
})
await db.user.update({
where: { id: session.user.id },
data: { avatarUrl: blob.url }
})
return { success: true, url: blob.url }
}
まとめ
Next.js 15のServer Actionsは、以下のメリットがあります。
- シンプルなコード: APIルート不要、直接サーバー関数を呼び出し
- 型安全性: TypeScriptで完全に型付け
- パフォーマンス: 自動的な最適化とキャッシュ管理
- セキュリティ: 自動CSRF保護、認証統合が容易
従来のAPIルートも併用できるため、段階的な移行が可能です。フォーム処理やデータ更新には積極的にServer Actionsを活用しましょう。