⚙️

Next.js 16 + React 19 + React Hook Form + Zod で「入力チェック付きフォーム

に公開

この記事は、Next.js / React / React Hook Form / Zod のバージョンを明記したうえで、

  • Zod でバリデーション
  • React Hook Form(RHF)でフォーム入力を扱い
  • 送信で Server Actions(= React の Server Functions)を動かす

ところまでを、初心者向けに基礎から説明します。

また、次のような「ありがちな疑問・勘違い」を解消します:

  • 「Server Actions って <form action> + useActionState のことだけ?」

  • 「RHF を使うなら <form action> ではなく onSubmit={handleSubmit(...)} が定石なのはなぜ?」

  • 「クライアントで submitContact() を呼んでいるのに、なぜその先はサーバー処理として実行できるの?」

  • 👉 **環境構築手順(コマンド / package.json)**は後半にまとめています:
    環境構築手順へ

2026/01時点で、npx create-next-app@latest を--yes オプション付きで全て、質問はすべてyesで環境作ったところ、
Next.js 16.1.1で構成され、TypeScript / Tailwind CSS / ESLint / App Router / Turbopack が有効になり、
Tailwind を「すぐ使える状態」でプロジェクトが作られます。
所定の位置に、フォルダを切って、ソースコードを配置するだけ( ゼロコンフィグ )で、
動作するため、非常にお手軽です。
Next.jsが流行ってる理由は、このゼロコンフィグでお手軽なところではないか。


検証に使ったバージョン(package-lock.json で固定)

  • Next.js: 16.1.1
  • React: 19.2.3
  • react-dom: 19.2.3
  • react-hook-form: 7.69.0
  • Zod: 4.3.2
  • @hookform/resolvers: 5.2.2

まず「Server Actions」って何?

「Server Actions」という言葉は、<form action> の書き方だけを指すものではありません。

Next.js の公式ドキュメントでは、Server Actions を次のように説明しています(要点):

  • サーバーで実行される async 関数
  • "use server" ディレクティブで定義する
  • <form action> で呼べる
  • さらに <form> に限らず、イベントハンドラやサードパーティライブラリ(RHF など)からも呼べる

(出典:Next.js docs「Server Actions and Mutations」)

そして React 側でも "use server" の関数(Server Functions)について:

  • フレームワーク(Next.js など)がサーバー参照を自動生成する
  • クライアントから呼ぶと、サーバーへリクエストしてサーバー側で実行し、結果を返す

(出典:React docs「Server Functions」「use server」)

ここまでが「Server Actions(Server Functions)」の定義です。

補足:Server Actions は、React の Server Functions("use server" をフレームワーク側が実行可能にした仕組みです。React 本体は「Server Function の参照を作り、クライアント呼び出しをサーバー実行へ変換する」ための統合点を提供し、実際の実装は Next.js などのフレームワーク/バンドラが担います。
Next.js(App Router)以外にも、RSC 対応を進めている React Router 、RSC フレームワークの Waku 、RSC/Server Functions を提供する RedwoodSDK などがあります。
また React 19 は安定版(stable)としてリリースされ、React 19 の React Server Components は「minor バージョン間で破壊しない stable」と説明されています(ただし実装側の低レベル API は semver 対象外の注意も記載)。


<form action> + useActionState は Server Actions の“代表的な使い方”のひとつ

Next.js の公式ガイド(Forms)には、useActionState を使って pending(送信中)やエラー表示を扱い、
<form action={formAction}> で送る例が載っています。

(出典:Next.js docs「Forms」)

つまり、

  • <form action> 方式は Server Actions の代表的な呼び出し方
  • ただし Server Actions そのものの定義が <form action> に限定されているわけではない

という整理になります。


ここが本題:なぜ RHF のときに <form action> ではなく onSubmit={handleSubmit(...)} が定石になりやすいのか

よくある疑問:

RHF を使うなら、なぜ <form action={serverAction}> ではなく、onSubmit={handleSubmit(onSubmit)} がよく使われるの?

重要ポイント:RHF の handleSubmit は “ブラウザの通常 submit” を止める(preventDefault する)

RHF は、フォーム送信を「ブラウザの標準 submit」ではなく、JavaScript 側で主導して扱う設計です。
そのため handleSubmit は内部で event.preventDefault() を呼びます

この挙動が原因で、フレームワーク側のフォーム送信(例:Remix の <Form>)と衝突する例が、RHF の公式 GitHub Issue に明確に書かれています:

この事実から、次が言えます:

  • <form action={serverAction}> が期待しているのは “通常の submit”
  • しかし onSubmit={handleSubmit(...)} を付けると、RHF が 通常の submit を止める
  • だから <form action> 方式で「submit として Server Action を発火させるルート」は噛み合いにくい

✅ よって、「RHF を使う= <form action> が絶対不可能」ではありません。
ただし RHF の定石(handleSubmit で送信制御)を取る限り、<form action> 方式の“通常 submit 経路”とは両立しにくい、が正確な理解です。


RHF + Server Actions の定石は?

Next.js 公式は「Server Actions は <form> に限らず、イベントハンドラやサードパーティライブラリから呼べる」と明記しています:

なので RHF での定石は次の形になります:

  1. RHF が handleSubmit で入力値を集める(+クライアント側バリデーション)
  2. onSubmit の中で Server Action を普通に呼ぶ

もう一段深い話:「なぜ submitContact() を呼んだ先が“サーバー処理”になれるのか?」

クライアント側コードで submitContact(values) を呼んでいるのに、
なぜその先はサーバー処理として実行できるのでしょうか?

境界は "use server" ディレクティブ

サンプルでは actions.ts の先頭に "use server" が書かれています。
Next.js 公式は "use server" により Server Actions を定義できること、そしてクライアント側が import できる制約などを説明しています。

(出典:Next.js docs「Server Actions and Mutations」)

理屈は「クライアントバンドルには“参照”だけが入り、実体はサーバーで実行」

React 公式は、"use server" の関数が サーバー参照として扱われ、クライアントから呼ぶとサーバーへリクエストして実行されることを説明しています。

(出典:React docs「Server Functions」)

Next.js 公式も、Server Actions は POST で呼ばれ、引数・戻り値にはシリアライズ制約があることを説明しています。

(出典:Next.js docs「Server Actions and Mutations」)


サンプルコード(ファイル単位で全文)と解説

この章では、実際に動く最小構成を ファイル単位で全文掲載し、要点を解説します。
(相対パスはコードブロックの直前に明記します)


app/features/contact/schema.ts

import { z } from "zod";

export const contactSchema = z.object({
  name: z.string().min(1, "名前は必須です"),
  email: z.email({ message: "メールアドレスの形式が不正です" }),
  message: z
    .string()
    .min(1, "メッセージは必須です")
    .max(1000, "1000文字までです"),
});

export type ContactInput = z.infer<typeof contactSchema>;

ポイント

  • Zod のスキーマは クライアント/サーバーで共通利用できます。
  • 今回は
    • useForm({ resolver: zodResolver(contactSchema) })クライアント側バリデーション
    • Server Action 内の safeParse()サーバー側バリデーション
      という二重構えです(クライアント入力は改ざんできるので、サーバー側検証は必須)。

app/features/contact/actions.ts

"use server";

import { contactSchema, type ContactInput } from "./schema";

export type SubmitResult =
  | { ok: true; message: string }
  | {
      ok: false;
      message: string;
      fieldErrors?: Record<string, string[] | undefined>;
    };

export async function submitContact(
  input: ContactInput
): Promise<SubmitResult> {
  const parsed = contactSchema.safeParse(input);

  if (!parsed.success) {
    const flattened = parsed.error.flatten();
    return {
      ok: false,
      message: "サーバー側バリデーションに失敗しました",
      fieldErrors: flattened.fieldErrors,
    };
  }

  // ここが "サーバーロジック" の場所(DB保存や外部API呼び出し等に置き換える)
  console.log("submitContact (server):", parsed.data);

  return {
    ok: true,
    message: "送信しました(Server Actions が実行されました)",
  };
}

ポイント

  • 先頭の "use server" が境界です(このファイルの export が Server Action になります)。
  • safeParse()サーバー側でも必ず検証しています。
  • DB 保存や外部 API 呼び出しは、この関数内に置けば「サーバー処理」として実行されます。

const parsed = contactSchema.safeParse(input); は何か?

これは一言でいうと、信頼できない入力データ input を、Zod スキーマ contactSchema で実行時に検証し、成功/失敗を「例外なし」で受け取る処理です。

Zod の .parse() は失敗すると 例外(ZodError)を throwしますが、.safeParse()例外を投げずに結果オブジェクトを返します(Zod 公式の説明・サンプルどおり)。

なぜサーバー側で safeParse しなきゃならないの?

理由は1つだけです:サーバーは、クライアントから来たデータを信用してはいけないからです。

React Hook Form や Zod Resolver でクライアント側バリデーションをしていても、ネットワーク経由でサーバーに届く値は改ざんできます
だからサーバー側でも 必ずスキーマ検証して、正しい入力だけを通します。

input は何か?どんな型・どんな形のオブジェクトか?

このプロジェクトでは、input の型は ContactInput です(schema.ts の定義)。

export type ContactInput = z.infer<typeof contactSchema>;

contactSchemaname / email / message を持つ z.object(...) なので、ContactInput は次の形です。

type ContactInput = {
  name: string;
  email: string;
  message: string;
};

具体例(正しい形の input)はこうです。

const input: ContactInput = {
  name: "山田太郎",
  email: "taro@example.com",
  message: "お問い合わせです。",
};

戻り値 parsed は何か?どんな型・どんな形のオブジェクトか?

Zod 公式 README の safeParse の説明どおり、戻り値は successtrue/false で分岐できる形です。

このプロジェクト(transform 等を使っていない)では、概念的には次の形になります。

type SafeParseResult =
  | { success: true; data: ContactInput }
  | { success: false; error: z.ZodError<ContactInput> };

成功したとき(parsed.success === true

parsed.data は **検証済みデータ(ContactInput)**です。

if (parsed.success) {
  // parsed.data は ContactInput 型
  parsed.data.name;    // string
  parsed.data.email;   // string
  parsed.data.message; // string
}

失敗したとき(parsed.success === false

parsed.errorZodError です。

if (!parsed.success) {
  // parsed.error は ZodError
  parsed.error;
}

parsed.error.flatten() は何を返すの?

Zod の公式ドキュメント(Error formatting)どおり、flatten() は次の形のオブジェクトを返します。

const flattened = parsed.error.flatten();

/*
flattened = {
  formErrors: string[],
  fieldErrors: {
    [fieldName: string]: string[]
  }
}
*/

まとめ(この1行がやってること)

const parsed = contactSchema.safeParse(input);

この1行は、サーバーに届いた input をスキーマで検証し、

  • ✅ 正しければ success: truedata に「検証済みデータ」
  • ❌ 間違っていれば success: falseerror に「エラー詳細」

を返す “サーバー側の安全装置” です。


app/features/contact/contact-form.tsx

"use client";

import { useState, useTransition } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";

import { contactSchema, type ContactInput } from "./schema";
import { submitContact, type SubmitResult } from "./actions";

export default function ContactForm() {
  const [result, setResult] = useState<SubmitResult | null>(null);
  const [isPending, startTransition] = useTransition();

  const {
    register,
    handleSubmit,
    reset,
    formState: { errors },
  } = useForm<ContactInput>({
    resolver: zodResolver(contactSchema),
    defaultValues: { name: "", email: "", message: "" },
    mode: "onBlur",
  });

  const onSubmit = (values: ContactInput) => {
    setResult(null);

    startTransition(async () => {
      const r = await submitContact(values);
      setResult(r);
      if (r.ok) reset();
    });
  };

  return (
    <div className="w-full max-w-2xl mx-auto">
      <form
        onSubmit={handleSubmit(onSubmit)}
        className="space-y-6 bg-white dark:bg-gray-800 rounded-2xl shadow-xl p-8 border border-gray-200 dark:border-gray-700"
      >
        <div className="space-y-2">
          <label
            htmlFor="name"
            className="block text-sm font-semibold text-gray-700 dark:text-gray-300"
          >
            名前
          </label>
          <input
            id="name"
            {...register("name")}
            className="w-full px-4 py-3 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-200"
            placeholder="お名前を入力してください"
          />
          {errors.name && (
            <p className="text-sm text-red-500 dark:text-red-400 mt-1 flex items-center gap-1">
              <span>⚠️</span>
              {errors.name.message}
            </p>
          )}
        </div>

        <div className="space-y-2">
          <label
            htmlFor="email"
            className="block text-sm font-semibold text-gray-700 dark:text-gray-300"
          >
            メールアドレス
          </label>
          <input
            id="email"
            type="email"
            {...register("email")}
            className="w-full px-4 py-3 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-200"
            placeholder="example@email.com"
          />
          {errors.email && (
            <p className="text-sm text-red-500 dark:text-red-400 mt-1 flex items-center gap-1">
              <span>⚠️</span>
              {errors.email.message}
            </p>
          )}
        </div>

        <div className="space-y-2">
          <label
            htmlFor="message"
            className="block text-sm font-semibold text-gray-700 dark:text-gray-300"
          >
            メッセージ
          </label>
          <textarea
            id="message"
            {...register("message")}
            rows={6}
            className="w-full px-4 py-3 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-200 resize-none"
            placeholder="メッセージを入力してください"
          />
          {errors.message && (
            <p className="text-sm text-red-500 dark:text-red-400 mt-1 flex items-center gap-1">
              <span>⚠️</span>
              {errors.message.message}
            </p>
          )}
        </div>

        <button
          type="submit"
          disabled={isPending}
          className="w-full py-3 px-6 bg-linear-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 text-white font-semibold rounded-lg shadow-lg hover:shadow-xl transform hover:-translate-y-0.5 transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed disabled:transform-none disabled:shadow-lg"
        >
          {isPending ? (
            <span className="flex items-center justify-center gap-2">
              <svg
                className="animate-spin h-5 w-5"
                xmlns="http://www.w3.org/2000/svg"
                fill="none"
                viewBox="0 0 24 24"
              >
                <circle
                  className="opacity-25"
                  cx="12"
                  cy="12"
                  r="10"
                  stroke="currentColor"
                  strokeWidth="4"
                ></circle>
                <path
                  className="opacity-75"
                  fill="currentColor"
                  d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
                ></path>
              </svg>
              送信中...
            </span>
          ) : (
            "送信する"
          )}
        </button>

        {result && (
          <div
            className={`mt-6 p-4 rounded-lg border-2 ${
              result.ok
                ? "bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800"
                : "bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800"
            } transition-all duration-300 animate-in fade-in slide-in-from-top-2`}
          >
            <p
              className={`text-sm font-medium flex items-center gap-2 ${
                result.ok
                  ? "text-green-800 dark:text-green-300"
                  : "text-red-800 dark:text-red-300"
              }`}
            >
              <span className="text-lg">{result.ok ? "✅" : "❌"}</span>
              {result.message}
            </p>

            {!result.ok && result.fieldErrors && (
              <div className="mt-3 p-3 bg-white dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700">
                <pre className="text-xs text-gray-600 dark:text-gray-400 whitespace-pre-wrap overflow-x-auto">
                  {JSON.stringify(result.fieldErrors, null, 2)}
                </pre>
              </div>
            )}
          </div>
        )}
      </form>
    </div>
  );
}

ポイント(なぜ onSubmit={handleSubmit(onSubmit)} なのか)

  • RHF は handleSubmit によって送信を JS 主導にする設計です。
  • そして Next.js 公式は、Server Actions が <form> だけでなく イベントハンドラやサードパーティライブラリから呼べると明記しています。
    そのため RHF の onSubmit の中で submitContact(values) を呼ぶ形が成立します。

ポイント(startTransition の役割)

  • useTransition / startTransition は、送信中の UI(ボタン無効化など)を素直に書けるようにするために使えます。
    React 公式の Server Functions の説明では、useTransition を併用する例があります。

なぜ「ここで」startTransition を使う必要があったのか?(使わないとどうなる?)

結論:このサンプルは「送信中の状態(pending)を正しく取り出して、送信中UIを確実に出す」ために useTransition / startTransition を使っています。
送信(Server Function 呼び出し)自体は startTransition がなくても実行できますが、UI 側が不親切になります

1) startTransition を使わないと、isPending が取れない(=送信中UIを出しにくい)

React の Server Functions の説明では、Server Function を自分で呼ぶ場合に
useTransitionstartTransition で包むことで pending 状態にアクセスできる例が示されています。
https://react.dev/reference/rsc/server-functions
また、useTransition()[isPending, startTransition] を返すと明記されています。
https://react.dev/reference/react/useTransition

つまり、送信中にボタンを無効化する/「送信中…」を表示するのに isPending が必要で、
isPending を得るために startTransition を使っています。

2) startTransition を使わないと、送信結果の setState が “urgent(通常優先)更新” になる

React は Transition にした更新を **non-blocking(UI をブロックしない)**扱いにします。
逆に言えば、Transition にしない更新は 通常(urgent)更新として扱われます。
https://react.dev/reference/react/useTransition

このフォームでは、送信後に setResult(r) などで UI を更新します。
それを Transition にしておくと、React は「入力・描画の応答性」を保ちつつ更新を進められます。

3) 重要:await の後の setState は Transition 扱いにならない(公式の既知制約)

公式は await 後の state 更新は Transition として扱われないので、
必要なら 追加で startTransition で包めと明記しています。
https://react.dev/reference/react/useTransition#react-doesnt-treat-my-state-update-after-await-as-a-transition


まとめ(初心者向けに一言で)

  • startTransition「送信中(pending)を UI に出す」ために使う
  • そして 送信後の UI 更新を non-blocking にするために使う
  • startTransition を外すと、送信はできても 送信中UIが出せない/UI更新が通常優先になるので、体験が悪くなる

app/contact/page.tsx

import ContactForm from "../features/contact/contact-form";

export default function ContactPage() {
  return (
    <main className="min-h-screen bg-linear-to-br from-gray-50 via-blue-50 to-indigo-50 dark:from-gray-900 dark:via-gray-800 dark:to-gray-900 py-12 px-4 sm:px-6 lg:px-8">
      <div className="max-w-4xl mx-auto">
        <div className="text-center mb-12">
          <h1 className="text-4xl sm:text-5xl font-bold dark:text-white mb-4 bg-linear-to-r from-blue-600 to-indigo-600 bg-clip-text text-transparent">
            お問い合わせ
          </h1>
          <p className="text-lg text-gray-600 dark:text-gray-400 max-w-2xl mx-auto">
            ご質問やご意見がございましたら、お気軽にお問い合わせください。
            できる限り早くご返信いたします。
          </p>
        </div>
        <ContactForm />
      </div>
    </main>
  );
}

ポイント

  • ルーティング(/contact)に対応するページ。フォーム本体は features/contact 側に切り出し、ページ側は「配置して表示するだけ」にして見通しを良くしています。

app/layout.tsx

import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";

const geistSans = Geist({
  variable: "--font-geist-sans",
  subsets: ["latin"],
});

const geistMono = Geist_Mono({
  variable: "--font-geist-mono",
  subsets: ["latin"],
});

export const metadata: Metadata = {
  title: "Create Next App",
  description: "Generated by create next app",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body
        className={`${geistSans.variable} ${geistMono.variable} antialiased`}
      >
        {children}
      </body>
    </html>
  );
}

ポイント

  • App Router のレイアウト。全ページ共通の骨組み(<html>, <body>)や、フォント/グローバル CSS をここで適用します。

app/page.tsx

import Link from "next/link";

export default function Page() {
  return (
    <main className="min-h-screen bg-linear-to-br from-gray-50 via-blue-50 to-indigo-50 dark:from-gray-900 dark:via-gray-800 dark:to-gray-900 flex items-center justify-center px-4">
      <div className="text-center">
        <h1 className="text-4xl sm:text-5xl font-bold text-gray-900 dark:text-white mb-8">
          ようこそ
        </h1>
        <Link
          href="/contact"
          className="inline-block px-8 py-4 bg-linear-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 text-white font-semibold rounded-lg shadow-lg hover:shadow-xl transform hover:-translate-y-0.5 transition-all duration-200"
        >
          お問い合わせはこちら
        </Link>
      </div>
    </main>
  );
}

ポイント

  • トップページ(/)。このサンプルでは /contact へ誘導する入口として最小構成にしています。

環境構築手順

このサンプルは、以下の手順で作成して npm run dev で動作確認できます。

1. Next.js プロジェクトの作成

npx create-next-app@latest next16-rhf-zod-actions --yes

補足:
next16-rhf-zod-actions は 作成されるプロジェクト(フォルダ)名です。
--yes を付けると、create-next-app の質問プロンプトをスキップし、保存済みの設定(なければ推奨デフォルト)で雛形を生成します。推奨デフォルトでは TypeScript / Tailwind CSS / ESLint / App Router / Turbopack が有効になり、Tailwind を「すぐ使える状態」でプロジェクトが作られます。
なお「Tailwind がデフォルトに含まれる」ことは、少なくとも Next.js 13 の公式ドキュメント上で create-next-app --help--tailwind に “(default)” と明記されています。

2. Next.js、React、React DOM のインストール

npm i next@16.1.1 react@19.2.3 react-dom@19.2.3

2026/01時点の最新バージョン(next 16.1.1、react 19.2.3、react-dom 19.2.3)を固定してインストールしています。
もし、インストール時点の latest(dist-tag) を使って入れたいならば、npm i next@latest react@latest react-dom@latest(または npm i next react react-dom)のコマンドラインになります

3. 必要なライブラリのインストール

npm i react-hook-form@7.69.0 zod@4.3.2 @hookform/resolvers@5.2.2

2026/01時点の最新バージョン(react-hook-form 7.69.0、zod 4.3.2、@hookform/resolvers 5.2.2)を固定してインストールしています。
もし、インストール時点の latest(dist-tag) を使って入れたいならば、npm i react-hook-form@latest zod@latest @hookform/resolvers@latest(または npm i react-hook-form zod @hookform/resolvers)のコマンドラインになります

4. 起動

npm run dev

package.json

2026/01の上記の環境構築方法で、動作確認した時点のpackage.jsonは、下記のとおり

{
  "name": "next16-rhf-zod-actions",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "eslint"
  },
  "dependencies": {
    "@hookform/resolvers": "^5.2.2",
    "next": "^16.1.1",
    "react": "^19.2.3",
    "react-dom": "^19.2.3",
    "react-hook-form": "^7.69.0",
    "zod": "^4.3.2"
  },
  "devDependencies": {
    "@tailwindcss/postcss": "^4",
    "@types/node": "^20",
    "@types/react": "^19",
    "@types/react-dom": "^19",
    "eslint": "^9",
    "eslint-config-next": "16.1.1",
    "tailwindcss": "^4",
    "typescript": "^5"
  }
}

まとめ

  • Server Actions は <form action> 専用ではない"use server" の async 関数をサーバーで実行する仕組み)。
  • RHF の定石は onSubmit={handleSubmit(...)}handleSubmit が通常 submit を止めて JS 主導にする設計だから。
  • submitContact(values) がサーバー処理になれる境界は "use server":クライアント側は参照を呼び、実体はサーバーで実行される。

参考(公式)

Discussion