← 記事一覧に戻る

Next.js 15 Server Actions が動かない・エラーになる時の5つの原因と対処法【App Router / React 19対応】

Next.js 15 Server Actions が動かない・エラーになる

Server Actions は Next.js 14 で安定版となり、フォーム送信やデータ更新の定番パターンになりました。しかし Next.js 15 + React 19 への移行で 破壊的変更が入っており、以前の記事の通りに書いても動かないケースが増えています。

よく見るエラー:

Error: Functions cannot be passed directly to Client Components unless
you explicitly expose it by marking it with "use server".
Error: Only plain objects, and a few built-ins, can be passed to Server Actions.
Classes or null prototypes are not supported.
Warning: useFormState is deprecated. Use useActionState instead.

順番に原因と対処法を解説します。

原因1:‘use server’ ディレクティブの位置が間違っている

コンポーネントファイルの先頭'use server' を書くと、そのファイル全体が Server Component 扱いになり、クライアント側の処理が動かなくなります。

// ❌ NG: コンポーネントファイルのトップに書いている
'use server'

export default function ContactForm() {
  // このファイル全体がサーバー扱いになってしまう
  const handleSubmit = async (formData: FormData) => {
    // ...
  }
  return <form action={handleSubmit}>...</form>
}

正しい書き方1:Server Actions を別ファイルに切り出す

// app/actions/contact.ts
'use server'  // ← このファイル全体が Server Actions になる

export async function submitContact(formData: FormData) {
  const email = formData.get('email') as string
  // DB保存・メール送信など
  return { success: true }
}
// app/contact/page.tsx(Server Component または Client Component)
import { submitContact } from '../actions/contact'

export default function ContactForm() {
  return <form action={submitContact}>...</form>
}

正しい書き方2:インライン関数の先頭に書く

// app/contact/page.tsx(Server Component 内のみ有効)
export default function ContactPage() {
  async function handleSubmit(formData: FormData) {
    'use server'  // ← 関数スコープの先頭に書く
    const email = formData.get('email') as string
    // ...
  }
  return <form action={handleSubmit}>...</form>
}

インラインの 'use server' は Server Component 内でしか使えません。Client Component('use client' を付けたファイル)でインライン Server Action は定義できないため、必ず別ファイルに切り出してください。

原因2:シリアライズできないデータを返している

Server Actions がクライアントに返せるのは JSON シリアライズ可能なデータのみです。DateMapSet、クラスインスタンスなどはそのまま返せません。

// ❌ NG: Date オブジェクトをそのまま返す(Prisma の場合によくある)
'use server'

export async function getUser(id: string) {
  const user = await db.user.findUnique({ where: { id } })
  return user  // createdAt が Date 型の場合エラー
}
Error: Only plain objects, and a few built-ins, can be passed to Server Actions.
Classes or null prototypes are not supported.

対処法:Date を ISO 文字列に変換して返す

// ✅ OK: Date を ISO 文字列に変換
'use server'

export async function getUser(id: string) {
  const user = await db.user.findUnique({ where: { id } })
  if (!user) return null
  return {
    ...user,
    createdAt: user.createdAt.toISOString(),
    updatedAt: user.updatedAt.toISOString(),
  }
}

また、引数も同様にシリアライズ可能な型のみ受け取れます。ファイルアップロードは File オブジェクトではなく FormData 経由で渡す必要があります。

原因3:useFormState が useActionState に変わった(React 19)

React 18 + Next.js 14 では react-domuseFormState を使っていましたが、React 19 で useActionState に名前が変わりました

// ❌ NG: React 19 では useFormState は非推奨(将来的に削除される)
import { useFormState } from 'react-dom'

export function ContactForm() {
  const [state, formAction] = useFormState(submitContact, null)
  // ...
}

対処法useActionState に移行する

// ✅ OK: React 19 の useActionState を使う
'use client'

import { useActionState } from 'react'  // react-dom ではなく react からインポート

export function ContactForm() {
  const [state, formAction, isPending] = useActionState(submitContact, null)

  return (
    <form action={formAction}>
      <input name="email" type="email" required />
      <button type="submit" disabled={isPending}>
        {isPending ? '送信中...' : '送信する'}
      </button>
      {state?.error && <p style={{ color: 'red' }}>{state.error}</p>}
      {state?.success && <p>送信しました!</p>}
    </form>
  )
}

変更点のまとめ:

項目 React 18(旧) React 19(新)
関数名 useFormState useActionState
インポート元 react-dom react
戻り値 [state, action] の2要素 [state, action, isPending] の3要素

React 19 では isPending が第3要素として返るようになったため、useFormStatus を別途使う必要がなくなりました。

原因4:Server Actions でエラーをそのまま throw している

Server Actions 内で throw new Error('...') すると、本番環境ではエラーメッセージが隠蔽されます。Next.js が「An error occurred in the Server Components render.」のような汎用メッセージに置き換えるため、ユーザーにも開発者にも何が起きたか分かりません。

// ❌ NG: エラーをそのまま throw する
'use server'

export async function submitContact(formData: FormData) {
  const email = formData.get('email') as string
  if (!email) {
    throw new Error('メールアドレスが必要です')  // 本番では詳細が見えない
  }
}

対処法:バリデーションエラーは戻り値として返す

// ✅ OK: エラーを戻り値として返す
'use server'

type ActionResult = {
  success: boolean
  error?: string
}

export async function submitContact(
  prevState: ActionResult | null,
  formData: FormData
): Promise<ActionResult> {
  const email = formData.get('email') as string
  if (!email) {
    return { success: false, error: 'メールアドレスが必要です' }
  }

  try {
    await sendEmail(email)
    return { success: true }
  } catch {
    return { success: false, error: 'メール送信に失敗しました。時間をおいて再試行してください。' }
  }
}

useActionState と組み合わせると、戻り値の error を直接フォームに表示できます。予期しない例外(DB 接続エラーなど)は throw してもよいですが、ユーザー向けのバリデーションエラーは必ず戻り値に含めてください。

原因5:redirect() が Server Action 内でエラーになる

redirect()try/catch で囲むと動きません。redirect() は内部的に特殊な例外をスローする仕組みになっており、catch に捕まると NEXT_REDIRECT エラーとして扱われてしまいます。

// ❌ NG: try/catch で redirect を囲んでいる
'use server'

import { redirect } from 'next/navigation'

export async function loginAction(formData: FormData) {
  try {
    await authenticate(formData)
    redirect('/dashboard')  // ← catch に捕まってしまう
  } catch {
    return { error: 'ログインに失敗しました' }
  }
}
Error: NEXT_REDIRECT  ← redirect が例外として catch される

対処法1isRedirectError で redirect 例外を再スロー

// ✅ OK: redirect エラーは再スローする
'use server'

import { redirect } from 'next/navigation'
import { isRedirectError } from 'next/dist/client/components/redirect'

export async function loginAction(formData: FormData) {
  try {
    await authenticate(formData)
    redirect('/dashboard')
  } catch (e) {
    if (isRedirectError(e)) throw e  // redirect は再スロー
    return { error: 'ログインに失敗しました' }
  }
}

対処法2redirect() を try/catch の外に出す

// ✅ OK: 成功フラグで分岐し、try/catch の外でリダイレクト
'use server'

import { redirect } from 'next/navigation'

export async function loginAction(formData: FormData) {
  let succeeded = false

  try {
    await authenticate(formData)
    succeeded = true
  } catch {
    return { error: 'ログインに失敗しました' }
  }

  if (succeeded) {
    redirect('/dashboard')  // try/catch の外なので問題なし
  }
}

まとめ

症状 原因 対処法
Functions cannot be passed to Client Components 'use server' の位置が間違い 関数の先頭または別ファイルに 'use server' を移動
Classes or null prototypes are not supported Date/Map などをそのまま返している ISO 文字列など JSON 変換可能な型に変換
useFormState is not a function React 19 で API が変わった useActionStatereact からインポート)に移行
本番でエラー詳細が見えない エラーを throw している バリデーションエラーは戻り値として返す
NEXT_REDIRECT エラー redirect を try/catch で囲んでいる isRedirectError で再スロー or try/catch の外に移動

Next.js 15 + React 19 への移行では、特に useFormStateuseActionState の変更と redirect() の扱いが詰まりポイントです。公式ドキュメントも頻繁に更新されているため、古い記事を参考にする際は Next.js のバージョンを必ず確認するようにしてください。