Skip to main content
友田 陽大
React forms
React
React Hook Form
Next.js
Zod
TypeScript
フォーム
型安全
セキュリティ
フロントエンド

React Hook Form × Next.js Server Actions practical guide [latest 2026] — useActionState, double validation, progressive enhancement

A practical guide to safely combining React Hook Form and Server Actions in the Next.js App Router (React 19). It compares the three patterns client-first (RHF leading) / progressive-first (useActionState) / hybrid, and explains in production-quality real code client/server double validation with one Zod schema, reflecting server errors via setError, double-submit prevention and idempotency, and authorization, rate limiting, and PII protection.

Published
Reading time
12 min read
Author
友田 陽大
Share

When building a form in the Next.js App Router (React 19), "should I use React Hook Form, is Server Actions + useActionState enough, or can they coexist" is the most common worry. This article digs into three realistic patterns and their safe implementation, with the official specs of React useActionState, Next.js Server Actions, and React Hook Form as primary sources. The validation schema presupposes the Zod 4 practical guide.

The versions verified (as of June 2026): Next.js 16, React 19, react-hook-form v7 family, @hookform/resolvers v5 family, zod v4.


0. Conclusion: choose among three options by requirements

PatternLeadWorks without JSInstant-validation UXSuited scene
A. client-firstReact Hook FormNoStrongAdmin panels, post-login business forms. Rich UX needed
B. progressive-firstuseActionStateYesWeakPublic forms, inquiries, reach-first
C. hybridRHF + native actionYesStrongPublic and rich. The implementation cost is the highest

The axes for choosing are two — (1) do you need to be able to submit even in a JS-disabled environment, and (2) do you need fine-grained validation UX during input. If only one, A or B suffices. If both, consider C. However, there's one grand principle common to every pattern.


1. The grand principle: don't trust the client (the trust boundary is the server)

Client-side validation is for UX, not security. A request that slips past zodResolver and is tampered with arrives just fine. So —

Apply the same Zod schema on both the client and the server. The server-side validation is "the truth."

If you make the schema one, the types, rules, and wording are centralized (DRY), and double validation is also written naturally. Place the schema in a pure module independent of both "use client" and "use server".

// lib/schemas/contact.ts —— クライアント/サーバー共有の単一の正
import * as z from "zod";

export const contactSchema = z.object({
  email: z.email({ error: "メールアドレスを正しく入力してください" }),
  message: z.string().min(10, { error: "10文字以上で入力してください" }).max(2000),
  // 二重送信対策の冪等キー(クライアント生成・UUID)
  idempotencyKey: z.uuid(),
});
export type Contact = z.infer<typeof contactSchema>;

2. Pattern A: client-first (RHF leading)

The richest UX. RHF owns the form, and inside handleSubmit it calls the Server Action as an ordinary async function and reflects the returned result onto the form.

2-1. The Server Action (as RPC)

// app/contact/actions.ts
"use server";
import * as z from "zod";
import { contactSchema, type Contact } from "@/lib/schemas/contact";
import { auth } from "@/lib/auth";
import { rateLimit } from "@/lib/rate-limit";

// 返り値は判別可能ユニオン。クライアントはこれを型として受け取れる
type SubmitResult =
  | { ok: true }
  | { ok: false; formError?: string; fieldErrors?: Partial<Record<keyof Contact, string>> };

export async function submitContact(input: unknown): Promise<SubmitResult> {
  // 1) 認可:誰がこの操作をしてよいか(Server Action は公開エンドポイント)
  const session = await auth();
  if (!session) return { ok: false, formError: "ログインが必要です" };

  // 2) レート制限:濫用・スパムを止める
  const allowed = await rateLimit(`contact:${session.userId}`);
  if (!allowed) return { ok: false, formError: "時間をおいて再度お試しください" };

  // 3) 検証:信頼境界。input は unknown として必ず parse する
  const parsed = contactSchema.safeParse(input);
  if (!parsed.success) {
    const { fieldErrors } = z.flattenError(parsed.error);
    return {
      ok: false,
      // 各フィールドの先頭メッセージだけ返す
      fieldErrors: Object.fromEntries(
        Object.entries(fieldErrors).map(([k, v]) => [k, v?.[0]]),
      ) as SubmitResult["fieldErrors"],
    };
  }

  // 4) 冪等性:同じ idempotencyKey の重複実行を弾く
  const created = await db.contacts.insertIfAbsent(parsed.data); // key 重複なら no-op
  if (!created) return { ok: true }; // 既に処理済み=成功扱い(リトライを正常系に)

  return { ok: true };
}

2-2. The form (client)

"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { contactSchema, type Contact } from "@/lib/schemas/contact";
import { submitContact } from "./actions";

export function ContactForm() {
  const {
    register,
    handleSubmit,
    setError,
    reset,
    formState: { errors, isSubmitting, isSubmitSuccessful },
  } = useForm<Contact>({
    resolver: zodResolver(contactSchema),
    defaultValues: { email: "", message: "", idempotencyKey: crypto.randomUUID() },
  });

  const onValid = async (data: Contact) => {
    const res = await submitContact(data); // Server Action を RPC として呼ぶ
    if (res.ok) {
      reset({ email: "", message: "", idempotencyKey: crypto.randomUUID() }); // 次回用に新キー
      return;
    }
    // サーバーの項目別エラーを該当フィールドへ
    for (const [name, message] of Object.entries(res.fieldErrors ?? {})) {
      if (message) setError(name as keyof Contact, { message });
    }
    // 全体エラーは root.server へ
    if (res.formError) setError("root.server", { message: res.formError });
  };

  return (
    <form onSubmit={handleSubmit(onValid)} noValidate className="space-y-4">
      <div>
        <label htmlFor="email">メールアドレス</label>
        <input
          id="email"
          type="email"
          {...register("email")}
          aria-invalid={errors.email ? "true" : "false"}
          aria-describedby={errors.email ? "email-error" : undefined}
        />
        {errors.email && <p id="email-error" role="alert">{errors.email.message}</p>}
      </div>

      <div>
        <label htmlFor="message">お問い合わせ</label>
        <textarea
          id="message"
          {...register("message")}
          aria-invalid={errors.message ? "true" : "false"}
          aria-describedby={errors.message ? "message-error" : undefined}
        />
        {errors.message && <p id="message-error" role="alert">{errors.message.message}</p>}
      </div>

      {errors.root?.server && <p role="alert">{errors.root.server.message}</p>}
      {isSubmitSuccessful && <p role="status">送信しました。ありがとうございます。</p>}

      {/* 送信中は無効化=二重送信を構造的に防ぐ */}
      <button disabled={isSubmitting}>{isSubmitting ? "送信中…" : "送信"}</button>
    </form>
  );
}

The strengths of this composition: instant validation, per-field server errors, double-submit prevention, type safety (submitContact's return value is a discriminated union). The downside is JS being required. For post-login admin panels and business forms, this is optimal.


3. Pattern B: progressive-first (useActionState)

Emphasizes progressive enhancement so it can submit even before JS loads. With useActionState + native <form action>, you use the Server Action as a form action.

// app/contact/actions.ts(progressive 版)
"use server";
import * as z from "zod";
import { contactSchema } from "@/lib/schemas/contact";

export type FormState = {
  ok: boolean;
  formError?: string;
  fieldErrors?: Record<string, string[] | undefined>;
};

export async function contactAction(_prev: FormState, formData: FormData): Promise<FormState> {
  // FormData → オブジェクト化して Zod で検証(サーバーが唯一の検証者)
  const parsed = contactSchema.safeParse({
    email: formData.get("email"),
    message: formData.get("message"),
    idempotencyKey: formData.get("idempotencyKey"),
  });
  if (!parsed.success) {
    return { ok: false, fieldErrors: z.flattenError(parsed.error).fieldErrors };
  }
  await db.contacts.insertIfAbsent(parsed.data);
  return { ok: true };
}
"use client";
import { useActionState } from "react";
import { useFormStatus } from "react-dom";
import { contactAction, type FormState } from "./actions";

const initial: FormState = { ok: false };

function SubmitButton() {
  const { pending } = useFormStatus(); // form 内の子で pending を取る
  return <button disabled={pending}>{pending ? "送信中…" : "送信"}</button>;
}

export function ContactForm() {
  const [state, formAction] = useActionState(contactAction, initial);
  return (
    <form action={formAction} noValidate className="space-y-4">
      <label htmlFor="email">メールアドレス</label>
      <input id="email" name="email" type="email" required aria-invalid={!!state.fieldErrors?.email} />
      {state.fieldErrors?.email && <p role="alert">{state.fieldErrors.email[0]}</p>}

      <label htmlFor="message">お問い合わせ</label>
      <textarea id="message" name="message" required minLength={10} />
      {state.fieldErrors?.message && <p role="alert">{state.fieldErrors.message[0]}</p>}

      <input type="hidden" name="idempotencyKey" value={crypto.randomUUID()} />
      {state.ok && <p role="status">送信しました。</p>}
      <SubmitButton />
    </form>
  );
}

Strengths: even with JS disabled, <form action> submits natively and is validated/processed on the server. Native attributes like required/minLength also provide minimal client validation. Downside: fine-grained instant validation during input (like pointing it out the moment you touch a field) is weak. Optimal for inquiries and public forms.


4. Pattern C: hybrid (RHF's instant validation + no-JS fallback)

A combined technique for when "it's a public form, but in a JS environment I want to validate richly." There are two realistic implementations.

  1. Use RHF's <Form> component: if you specify action, after validation it POSTs via fetch; if you omit it, it becomes native submission (the relevant section of the React Hook Form complete guide). With progressive: true + shouldUseNativeValidation: true, you can also output native validation attributes.
  2. Layer RHF onto a native <form action>: while you register with useForm, also use <form action={formAction}>, and on the client, on onSubmit, go through handleSubmit before submitting, falling back to native submission when JS is disabled.

The honest trade-off: hybrid holds "two submission routes (fetch and native)" and "two validation routes," so keeping state synchronization and error-display consistency is the hardest. For many projects, either A or B is enough. If reach is the top priority, B; if UX is the top priority and you can presuppose JS, A. Choose C only when you're convinced "both are mandatory" (YAGNI).


5. Security: a Server Action is a "public endpoint"

The most important recognition — after build, a Server Action becomes an HTTP endpoint anyone can hit. Inside "use server", always do the following.

  • Authorization: confirm every time not only "is the user logged in" but "may this user perform this operation." Even if you hide it in the UI, the endpoint is alive.
  • Input validation: always safeParse the arguments with Zod as unknown. TypeScript's types are a compile-time promise and aren't a runtime defense.
  • Rate limiting: anti-spam, anti-brute-force. Narrow per user/IP (in this portfolio, lib/rate-limit).
  • Don't leave PII: don't log the email address or body as-is. Record only the correlation ID and phase tag.
  • Secrets: the Server Action's closure is encrypted, but don't rely on that to handle secrets with client-derived values. Secrets come from environment variables / a secrets manager.

For authorization/authentication design, see the authentication/authorization (OIDC/JWT) implementation guide, and for WAF / defense-in-depth, the WAF defense-in-depth guide.


6. Idempotency and resilience: make retries the "normal path"

The network drops, the user mashes the button, and requests duplicate. Design on this premise.

  • Client: during submission, stop the button with disabled={isSubmitting} (A) / disabled={pending} (B).
  • Payload: put a unique idempotencyKey (crypto.randomUUID()) on each submission.
  • Server: execute the processing for the same key only once (equivalent to insertIfAbsent). From the second time on, return as success — this keeps a retry from becoming a double charge or double registration.

The idempotency design for processing where "it must never be executed twice," like payments, is detailed in the idempotency guide for preventing double charges. If you want to reflect a list immediately after submission, use useOptimistic for optimistic updates (React 19 use/useOptimistic practical guide).


7. Testing: test the validation logic and the form separately

  • Unit test of the Server Action: call the function directly, pass unknown, and verify "it returns fieldErrors on invalid input / returns ok on valid input / rejects the unauthorized." Since the center of validation is Zod, if this is solid, most of the safety is guaranteed.
  • Integration test of the form: with @testing-library/react + user-event, operate via labels and verify error display and the disabling of the submit button (the testing section of the React Hook Form complete guide).
  • E2E: confirm the submission flow and the no-JS fallback (B/C) in a real browser (Playwright E2E practical guide).
// actions.test.ts —— 信頼境界そのものをテストする
import { describe, it, expect } from "vitest";
import { submitContact } from "./actions";

it("不正な入力は fieldErrors を返し DB に触れない", async () => {
  const res = await submitContact({ email: "bad", message: "短い", idempotencyKey: "x" });
  expect(res.ok).toBe(false);
  if (!res.ok) expect(res.fieldErrors?.email).toBeTruthy();
});

8. Decision flow (when in doubt, this)

  1. Do you need to be able to submit even with JS disabled? → If No, it's settled as A (client-first).
  2. Yes, and can you not compromise on instant-validation UX during input either? → If Yes, C (hybrid); if you can compromise, B (progressive-first).
  3. In any case, server-side Zod validation, authorization, rate limiting, and an idempotency key are mandatory. This doesn't depend on the pattern.

9. FAQ

Q. If I use Server Actions, isn't React Hook Form unnecessary? A. It depends on requirements. If you need instant, fine-grained validation UX, dynamic fields, or controlled-UI-library integration, RHF (A). For a simple public form, useActionState alone (B) suffices. Using both depending on the case is the correct answer.

Q. Isn't validating again on the server, when I'm already doing Zod validation on the client, wasteful? A. It's not wasteful but mandatory. Client validation is UX, server validation is security. If you share one schema, there's no effort of writing it twice (DRY).

Q. Are useActionState and useFormState the same? A. They're different things. useActionState (React 19) is state management for a Server Action. useFormStatus (react-dom) is the submitting flag. RHF's useFormState is RHF's form-state subscription. They just have similar names but different roles.

Q. How do I display the errors the server returned? A. In pattern A, with setError("field", ...) onto the field, and the overall onto root.server. In pattern B, read useActionState's state.fieldErrors. In either, notify assistive technology with role="alert".

Q. Is CSRF okay? A. Next.js's Server Actions are designed on the premise of same-origin and POST, and the framework provides basic protection. That said, authorization is mandatory on your own — "being able to hit it" and "being allowed to hit it" are different.


Summary: you can choose the pattern, the principle is one

RHF × Server Actions in Next.js isn't as complex as it looks. You just choose among three patterns by requirements and protect one grand principle in every pattern

  1. Choose A / B / C by "is JS required × is instant-validation UX needed." Many cases are fine with A or B.
  2. The trust boundary is the server. The same Zod at both ends. Server validation is the truth.
  3. A Server Action is a public endpoint. Always do authorization, validation, and rate limiting inside it.
  4. Prevent double submission structurally with the UI (disabled) + an idempotency key.
  5. Test the validation logic and the form separately, and protect the trust boundary itself.

This is design for building not "a working form" but "a form that doesn't break in production, isn't abused, and is fast." Inquiries and applications are the entrance to the business — the reliability here is directly tied to revenue.

If you need to design a safe form foundation in Next.js, a security review of Server Actions, or to bring an existing form up to production quality, please feel free to consult me. The case study below introduces the process of designing and implementing a B2B SaaS that supports an industry's core operations, with an emphasis on type safety, security, and reliability.

友田

友田 陽大

Developer of a METI Minister's Award–winning product. With TypeScript + Python + AWS, I deliver SaaS, industry DX, and production-grade generative AI (RAG) end to end — from requirements to infrastructure and operations — single-handedly.

Got a challenge?

From design to implementation and operations — solo × generative AI

Implementation like this article's, end to end from requirements to production. Start with a free 30-minute technical consult and tell me about your situation.

Available for both project-based (contract) and advisory engagements. Start with a free 30-minute consult.

Also worth reading