環境
検証していたのは自社の管理画面です。構成は以下のとおりです。
- Next.js 15.x(App Router / dev は Turbopack)
- React 19(
useActionStateを使用) - TypeScript 5.x
- テスト: Vitest + React Testing Library(jsdom)
- パッケージマネージャ: pnpm
- CI: ユニットテストのみ。
next buildはデプロイ時に実行
何が起きたか
管理画面のフォーム送信を、API Route から Server Action に寄せていました。Server Action はサーバー側の関数を画面から直接呼べる仕組みで、"use server" を付けたファイルに置いた async 関数がそのままクライアントから呼べます。
ユニットテストは全緑。Server Action の関数を直接 import して呼び出す検証も通っていました。ところが実画面で送信ボタンを押した瞬間、500 になります。
A "use server" file can only export async functions, found object.
"use server" を付けたファイルは、async function しか export してはいけない——というエラーです。
テストが緑だったので完了扱いにしており、その分まるごと手戻りになりました。
原因のコード
犯人はこれです。
// app/admin/users/actions.ts
"use server";
// ❌ これが 500 の原因
export const initialState: FormState = {
ok: false,
message: "",
};
export type FormState = {
ok: boolean;
message: string;
};
export async function createUser(
prev: FormState,
formData: FormData,
): Promise<FormState> {
const name = String(formData.get("name") ?? "");
if (!name) {
return { ok: false, message: "名前を入力してください" };
}
// ... DB 書き込みなど
return { ok: true, message: "作成しました" };
}
useActionState に渡す初期状態オブジェクトを、Server Action と同じファイルに export const していました。React 19 の useActionState(action, initialState) は初期値をクライアント側でも必要とするので、型と値を action の隣に置きたくなるんですよね。ここが落とし穴でした。
なぜテストは緑だったのか
テストコードはこう書いていました。
// actions.test.ts
import { createUser } from "./actions";
it("名前が空ならエラーを返す", async () => {
const fd = new FormData();
const result = await createUser({ ok: false, message: "" }, fd);
expect(result.ok).toBe(false);
});
Vitest は Node の ESM ローダーでモジュールを解決します。"use server" は Next.js のコンパイラ(SWC / Turbopack)が解釈するディレクティブであり、Vitest のトランスフォームでは単なる文字列リテラルとして扱われます。
つまり、Next.js のモジュールローダーを通らない検証では、この制約チェックが一切走らない。テストが検証していたのは「関数のロジック」だけで、「Next.js のモジュールとして妥当か」は検証対象外だったわけです。
| 検証手段 | ロジック | "use server" 制約 | 実際のバンドル |
|---|---|---|---|
| Vitest での直接 import | ✅ | ❌ 検知不能 | ❌ |
next build |
✅ | ✅ 検知できる | ✅ |
| 実画面でのフォーム送信 | ✅ | ✅ | ✅ |
解決:状態と型を別ファイルに切り出す
やることは単純で、export を action ファイルから追い出すだけです。
// app/admin/users/form-state.ts
export type FormState = {
ok: boolean;
message: string;
};
export const initialState: FormState = {
ok: false,
message: "",
};
// app/admin/users/actions.ts
"use server";
import type { FormState } from "./form-state";
export async function createUser(
prev: FormState,
formData: FormData,
): Promise<FormState> {
const name = String(formData.get("name") ?? "");
if (!name) {
return { ok: false, message: "名前を入力してください" };
}
return { ok: true, message: "作成しました" };
}
クライアント側は form-state.ts から型と初期値を import します。
// app/admin/users/user-form.tsx
"use client";
import { useActionState } from "react";
import { createUser } from "./actions";
import { initialState } from "./form-state";
ハマりポイント
-
export typeは許容される。型はコンパイル時に消える(type erasure)ため、制約チェックに引っかかりません。ただしexport { FormState }と書くと値の export と解釈されうるので、必ずexport typeを付けること。 -
定数・enum・Zod スキーマも全部アウト。
export const schema = z.object({...})を action ファイルに置けば同じエラーになります。スキーマはschema.tsに逃がしましょう。 -
export defaultも避ける。関数なら通りますが、useActionStateは参照を渡すだけなので named export のほうが事故が少ないです。 -
export const runtime = "nodejs"を action ファイルに書かない。セグメント設定はpage.tsx/layout.tsx側に書きます。
再発防止の手順
-
"use server"ファイルには async function 以外を置かない。定数・型・スキーマは必ず別ファイル。 - CI に
next buildを追加する。ユニットテストだけでは Next.js のコンパイラを通らないため、このクラスの不具合は原理的に検出できません。 - フォーム送信は Playwright で 1 本だけ E2E を書く。ログイン → 入力 → 送信 → トースト表示までを通すだけでも、今回の 500 は捕まります。
// e2e/user-create.spec.ts
import { test, expect } from "@playwright/test";
test("管理画面からユーザーを作成できる", async ({ page }) => {
await page.goto("/admin/users/new");
await page.getByLabel("名前").fill("テスト太郎");
await page.getByRole("button", { name: "作成" }).click();
// 500 ならここで落ちる
await expect(page.getByText("作成しました")).toBeVisible();
await expect(page).toHaveURL(/\/admin\/users$/);
});
完了条件を変えた
いちばん効いたのはこれです。
テストの緑ではなく、実画面で 1 回押したかを完了条件にする。
プルリクのチェックリストに「実画面で送信ボタンを押して確認」を明記し、スクリーンショットを貼る運用にしました。ユニットテストは「関数が正しいか」しか保証しません。フレームワークの約束事を守っているかは、フレームワークの上で動かさないと分かりません。
FAQ
Q. next build は通っていたのに、dev で 500 になったのはなぜ?
A. Turbopack の dev はモジュールを遅延コンパイルします。該当の action ファイルが最初に要求された時点でエラーになり、それが 500 として表面化します。手元の next build が CI に入っていなければ、本番デプロイまで気づけないこともあります。
Q. 型だけなら同じファイルに置いてもいい?
A. 実務上は問題ありませんが、initialState とセットで管理したほうが差分が読みやすいので、私は型も含めて form-state.ts に切り出しています。
Q. テストの緑が信用できないなら、ユニットテストは無意味?
A. いいえ。ロジックのリグレッション検出には効きます。ただしカバーできる範囲を理解しておく必要があります。今回の学びは「テストが緑 = 動く」ではなく「テストが緑 = テストが検証した範囲が正しい」だということでした。
実画面の確認、完了条件に入れていますか?
この記事を書いた人
BENTEN Web Works — 業務自動化・システム開発のフリーランスエンジニアです。
GAS / Python / RPA を使った業務自動化や、Web制作・システム開発のご相談を承っています。
「こんなこと自動化できる?」というご質問だけでもお気軽にどうぞ。
👉 BENTEN Web Works — 詳細・お問い合わせはこちら
🐦 X(旧Twitter) — 日々の知見を発信中