header background image

Next.jsのSSRフォームが送信できない:原因と3つの解決策(CSR/Server Actions/Remix的PE)

2025年10月19日☕️☕️☕️☕️ 16 min read

tl;dr

App Router環境の「普通」の組み合わせ(Next.js + react-hook-form + zod)でSSRすると、JSのロード/ハイドレーションが遅い環境では isValid などJS依存の検証状態が初期入力を取りこぼし、「Submitが押せない」などのUX劣化が起きる。
本記事ではその再現と、解決策を3通り(①SSRを捨ててCSR+Skeleton、②Server ActionsでJS前でも送信可能、③Remix的Progressive Enhancement)で整理する。

背景と前提

  • 前提の実装: Next.js(App Router) + react-hook-form + zod
  • Next.js はページを素直に書くとサーバー側でまずHTMLが返る(SSR/RSC)JSのロード/ハイドレーションは後からやって来る。
  • フォームのバリデーションや送信可否(isValid 等)をクライアントJSに依存している場合、ハイドレーションまでの入力イベントをライブラリが取りこぼすことがある。

再現

Chrome DevTools でネットワーク/CPU を絞って、JSが来る前に入力 → ハイドレ後も Submit が有効にならないという状態を再現できる。

下記のソースコードを参考にして、Chrome DevToolsで再現する。

  • https://github.com/thundermiracle/ssr-form
  • ネットワーク:Slow 4G にする
  • /login をハードリロード → JSが来る前にメールとパスワードを入力
  • ハイドレーション後も isValid が追いつかず、ボタンが無効のまま(1文字打ち直すと有効になる)

なぜ起こる?

SSR直後はHTMLしかない。クライアントJSが初期化されるまで、フォームライブラリは入力イベントを受け取れない
結果として、ハイドレーション後に状態を完全に再構築できずisValid などとDOM上の実入力が不整合になる。


解決策1:SSRをやめてCSRにする + Skeleton

最も割り切ったやり方。フォームを丸ごとCSRにして、SSR中はSkeletonでレイアウトの安定と視覚的な安心感を提供する。

src/components/login-form/index.ts
"use client";

import dynamic from "next/dynamic";
import { LoginFormSkeleton } from "./login-form-skeleton";

export const LoginForm = dynamic(
  () => import("./login-form").then((m) => m.LoginForm),
  { ssr: false, loading: LoginFormSkeleton }
);
src/components/login-form/login-form-skeleton.tsx
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";

export function LoginFormSkeleton() {
  return (
    <Card className="w-full max-w-sm animate-pulse">
      <CardHeader>
        <CardTitle>
          <span className="inline-block h-5 w-20 rounded bg-foreground/10 dark:bg-foreground/20" />
        </CardTitle>
      </CardHeader>
      <CardContent>
        <div className="grid gap-4">
          <div className="grid gap-2">
            <div className="h-4 w-24 rounded bg-foreground/10 dark:bg-foreground/20" />
            <div className="h-10 w-full rounded-md bg-foreground/10 dark:bg-foreground/20" />
          </div>
          <div className="grid gap-2">
            <div className="h-4 w-20 rounded bg-foreground/10 dark:bg-foreground/20" />
            <div className="h-10 w-full rounded-md bg-foreground/10 dark:bg-foreground/20" />
            <div className="h-3 w-40 rounded bg-foreground/10 dark:bg-foreground/20" />
          </div>
          <div className="h-10 w-full rounded-md bg-foreground/10 dark:bg-foreground/20" />
        </div>
      </CardContent>
    </Card>
  );
}

修正後動画(hydration前)

メリットとデメリット

メリット

  • SSR期の取りこぼし自体が起きない(最初からクライアントだけで完結)
  • SkeletonでCLS回避と心理的待機を両立

デメリット

  • TTFB/LCP/SEOはSSRより弱くなり得る
  • JS前送信(オフライン/JS無効など)が不可能
  • 既存SSR前提のページ構成だと方針転換の影響が大きい

解決策2:Server Actions を使い、JSが無くても submit できるようにする

Server Actions をフォームの action にそのままぶら下げ、JSが来る前でも(=ハイドレ前でも)ネイティブPOSTで送信できるようにする。
一方、JSが来た後は react-hook-form でUXを強化(即時バリデーション等)する 「二段構え」

src/components/login-form/login-action.ts
"use server";

import { redirect } from "next/navigation";
import { z } from "zod";

const LoginSchema = z.object({
  email: z.string().min(1).email(),
  password: z.string().min(8).max(128),
});

export async function login(formData: FormData) {
  const email = String(formData.get("email") ?? "");
  const password = String(formData.get("password") ?? "");

  const parsed = LoginSchema.safeParse({ email, password });
  if (!parsed.success) {
    redirect("/login?error=1");
  }

  // Demo only: replace with real auth/session handling as needed.
  redirect("/login?success=1");
}
src/components/login-form/login-form.tsx(フォームタグ付近)
<form
  action={action}
  method="post"
  // Disable native H5 validation after hydration; rely on RHF instead
  noValidate={mounted}
  onSubmit={onSubmit}
  className="grid gap-4"
>
  {/* ...inputs... */}
  <SubmitButton />
</form>
src/components/login-form/login-form.tsx(送信ボタン:
function SubmitButton() {
  const { pending } = useFormStatus();
  const disabled = pending; // Always active unless submitting
  return (
    <Button type="submit" disabled={disabled} aria-busy={pending} className="w-full">
      {pending ? "送信中..." : "ログイン"}
    </Button>
  );
}

補足page.tsx 側では action={login}LoginForm に渡し、結果は ?success=1 / ?error=1 をクエリで受けてSSR描画する。)

修正後動画(hydration前)

メリットとデメリット

メリット

  • JS未ロードでも送信可(ネイティブフォーム → Server Action)
  • RHF + zod により ハイドレ後の即時検証/エラーメッセージが可能
  • SSRのTTFB/SEOを維持しつつ、アクセシビリティaria-busy等)も担保しやすい

デメリット

  • 二重検証(クライアント/サーバ)の重複管理が発生(スキーマ共有で軽減)
  • 成功/失敗の状態伝搬設計(クエリ、Flash等)が必要
  • Server Actionの戻り値設計リダイレクトを理解して実装する初期コスト

解決策3:Remix Progressive Enhancement like(Routeなし・Server Actionのみ)

Remixの「まずはネイティブフォームで成立JS到着後に段階的にUX強化」という思想を、Next.jsのServer Actionだけで実現します。
Route Handler(route.ts)は不要にし、No-JSでもServer Action宛にネイティブPOST、JS有りなら useActionStateフィールド別エラーやローディングをリッチに扱います。検証は zodサーバ/クライアントで共有します。

Server Action(成功時はPRGリダイレクト、失敗時はフィールドエラーを返す)

src/components/login-form/login-action.ts
"use server";

import { redirect } from "next/navigation";
import { validate } from "@/components/login-form/validation";

export type LoginState = {
  errors: { email?: string; password?: string } | null;
  values: { email: string; password: string };
};
export async function login(_prevState: LoginState, formData: FormData): Promise<LoginState> {
  const values = {
    email: String(formData.get("email") || ""),
    password: String(formData.get("password") || ""),
  };
  const result = validate(values);

  if (!result.ok) {
    return {
      errors: {
        email: result.fieldErrors?.email,
        password: result.fieldErrors?.password,
      },
      values: { email: values.email, password: "" },
    };
  }
  // Simulate async work; replace with real auth
  await new Promise((r) => setTimeout(r, 200));
  redirect("/login?success=1");
}

共有バリデーション(zod)

src/components/login-form/validation.ts
import { z } from "zod";

export const LoginSchema = z.object({
  email: z
    .string({ required_error: "メールアドレスを入力してください" })
    .trim()
    .min(1, "メールアドレスを入力してください")
    .email("正しいメールアドレスを入力してください"),
  password: z
    .string({ required_error: "パスワードを入力してください" })
    .min(8, "8文字以上で入力してください")
    .max(128, "128文字以内で入力してください"),
});

export type LoginValues = z.infer<typeof LoginSchema>;
export type FieldErrors = Partial<Record<keyof LoginValues, string>>;

export type ActionState = {
  ok: boolean;
  message?: string;
  fieldErrors?: FieldErrors;
  values?: Partial<LoginValues>;
};
export function validate(values: Partial<LoginValues>): {
  ok: boolean;
  fieldErrors?: FieldErrors;
  data?: LoginValues;
} {
  const result = LoginSchema.safeParse(values);
  if (!result.success) {
    const fieldErrors = Object.fromEntries(
      Object.entries(result.error.flatten().fieldErrors).flatMap(([k, v]) =>
        v && v.length > 0 ? [[k, v[0] as string]] : []
      )
    ) as FieldErrors;
    return { ok: false, fieldErrors };
  }
  return { ok: true, data: result.data };
}

フォーム本体(No-JS/JS両対応:useActionStateaction={formAction}

src/components/login-form/login-form.tsx
"use client";
import * as React from "react";
import { useActionState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { validate } from "@/components/login-form/validation";
import type { LoginState } from "@/components/login-form/login-action";
const initialState: LoginState = {
  errors: null,
  values: { email: "", password: "" },
};

export function LoginForm({ action }: { action: (prev: LoginState, formData: FormData) => Promise<LoginState> }) {
  const [state, formAction, isPending] = useActionState<LoginState, FormData>(action as any, initialState);
  const [clientErrors, setClientErrors] = React.useState<{ email?: string; password?: string } | null>(null);
  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    const formData = new FormData(e.currentTarget);
    const email = String(formData.get("email") || "");
    const password = String(formData.get("password") || "");
    const result = validate({ email, password });
    if (!result.ok) {
      e.preventDefault();
      setClientErrors({ email: result.fieldErrors?.email, password: result.fieldErrors?.password });
    } else {
      setClientErrors(null);
    }
  };
  const emailError = clientErrors?.email ?? state.errors?.email;
  const passwordError = clientErrors?.password ?? state.errors?.password;
  return (
    <Card className="w-full max-w-sm">
      <CardHeader>
        <CardTitle>ログイン</CardTitle>
      </CardHeader>
      <CardContent>
        <form action={formAction} method="POST" onSubmit={handleSubmit} className="grid gap-4" noValidate>
          <div className="grid gap-2">
            <Label htmlFor="email">メールアドレス</Label>
            <Input
              name="email"
              type="text"
              inputMode="email"
              autoComplete="email"
              placeholder="you@example.com"
              defaultValue={state.values.email}
              className={emailError ? "border-red-500 focus-visible:ring-red-500/30" : undefined}
            />
            {emailError && (
              <p id="email-error" className="text-sm text-red-600" aria-live="polite">
                {emailError}
              </p>
            )}
          </div>
          <div className="grid gap-2">
            <Label htmlFor="password">パスワード</Label>
            <Input
              name="password"
              type="password"
              autoComplete="current-password"
              className={passwordError ? "border-red-500 focus-visible:ring-red-500/30" : undefined}
            />
            {passwordError ? (
              <p id="password-error" className="text-sm text-red-600" aria-live="polite">
                {passwordError}
              </p>
            ) : (
              <p className="text-xs text-foreground/70">8文字以上で入力してください</p>
            )}
          </div>
          <Button type="submit" disabled={isPending} aria-busy={isPending} className="w-full">
            {isPending ? "送信中..." : "ログイン"}
          </Button>
        </form>
      </CardContent>
    </Card>
  );
}

ページでの配線

Server ActionをそのままLoginFormに渡します。

修正後動画(hydration後)

  • エラー時
  • 成功時

ポイント(RouteなしのPE設計)

  • Route Handlerは削除し、Server ActionのみNo-JS/JSの両経路を成立させる。
    • No-JS: <form action={formAction}>Server ActionへネイティブPOST。成功時は redirect("/login?success=1")PRG
    • JSあり: useActionState返却stateをマージし、フィールド別エラーとpending状態を即時に表示。
  • zodを共有して、クライアントのライブ検証onChange/onSubmit)とサーバの信頼検証を一元化。

メリット / デメリット

メリット

  • Route不要・シンプル:エンドポイントの重複がなく、単一のServer ActionでNo-JS/JS双方をカバー。
  • PEの原則に忠実JSが無くても成立し、JS到着後は useActionState でUX強化。
  • スキーマ一元化:zod共有でズレのない検証型の再利用が容易。

デメリット

  • エラー時のNo-JS挙動は設計判断が必要:現状は成功時のみリダイレクト。No-JSでのエラーをURLクエリ等に出したい場合は、Server Action側でredirect("/login?error=1")などに寄せる方針を検討。
  • RHF資産が無い前提:既存のRHFロジック・コンポーネントを流用しない場合は移行コストがかかる。
  • UI側のライブ検証配線updateLiveErrors など)を自前で持つぶん、記述量はやや増える。

3方式の比較(ざっくり)

観点② Server Actions + RHF③ Remix的PE(Routeなし・Server Actionのみ)
クライアント検証RHF + zod(即時・高機能)自前 + zod(最小限・必要分だけ)
No-JS挙動SA 宛に送信は可能 / 送信前は止めにくいが到着後UXはRHFで厚くSA 宛に送信 / 成功はPRG失敗もPRGで表出する設計が素直
ハイドレーション耐性RHF stateに依存 → 前提設計が要注意サーバ返却state主導で整合しやすい
複雑フォームRHFの資産が強力(Field Array 等)都度実装(自由だが手数は増える)
依存/サイズRHFぶん増える(恩恵あり)依存少・軽量化しやすい
学習/運用負荷RHFの流儀 + SA + zod の三者連携SA + zod + 最小のクライアント状態で 思考がWeb標準寄り
適性既存RHF資産活用、複雑UI・細かな制御が要るサーバ一次主義PE/アクセシビリティ重視、軽量志向

どう選ぶ?

まず、速度最優先・SEO不要の会員系ページや暫定対処なら、CSR + Skeleton が手っ取り早い選択です。SSR起因の取りこぼしを原理的に回避しつつ、読み込み中の不安はSkeletonで抑えられます。ただしJS依存になるため、No-JS環境や超低速端末での初期体験は弱くなります。
次に、プロダクト全体の標準解としてバランスが良いのは Server Actions + RHF です。<form action={serverAction}> によりJS未ロードでも提出でき、JSが到着した後はRHF + zodで即時バリデーションとリッチなエラー表示を付与できます。SSRのTTFBやSEOも維持でき、既存のRHF資産も活かせます。そのぶん、クライアント/サーバの二重検証や成功・失敗の状態伝搬(クエリやFlashメッセージ等)の設計が必要です。
最後に、公共系・重要導線・アクセシビリティ重視で**“どんな環境でも確実に送れる”**体験を最優先するなら、Remix的PE(RHFを使わない版) を選びます。ネイティブPOST(Route Handler)でまず完成させ、JS到着後は useActionState + Server Action と zod共有でライブ検証やフィールド別エラーを段階的に上乗せします。堅牢でアクセシブルですが、RHF前提の実装からは配線を組み替えるコストがかかります。


まとめ

Next.js(App Router)は既定でSSR/RSCが動き、最初に届くのはHTMLだけです。クライアントJSが初期化されるまでの“空白時間”にユーザーがフォームへ入力すると、そのイベントは react-hook-form の内部状態(isValidisDirty など)に取り込まれないことがあります。結果として、表示上は値が埋まっているのに送信ボタンは無効のまま、あるいは1文字追加入力すると突然有効化――という齟齬が発生します。これはハイドレーションのタイミングとライブラリの状態復元が噛み合わないことが原因で、端末性能やネットワーク次第で再現性が揺れるのも厄介な点です。フォーム体験の信頼性を確保するには、「JS前でも提出可能であること」「到着後は即時に整合した検証が働くこと」「視覚的にもレイアウトが安定していること」を同時に満たす設計が求められます。

迷ったら、Server Actions + RHF を“デフォルトの型”として採用してください。JS前提出の確実性、到着後のリッチなUX、SSRの恩恵の三拍子が揃い、多くのフォームにとって最適な折衷点です。

一方で、フォームが事業の中核だったり アクセシビリティ/堅牢性を最優先するなら、Remixを第一候補に据えて構いません。Remixの設計は“まずWeb(フォームとHTTP)で正しく動き、その上にJSを重ねる”をフレームワークレベルで体現しており、今回のようなハイドレーション由来の取りこぼしを避けたまま、部分更新・再検証・エラーハンドリングを小さな思考単位で積み上げられます。

興味があれば試してみてください。

https://github.com/thundermiracle/ssr-form

ThunderMiracle

Blog part of ThunderMiracle.com

コメントは表示領域に入ると読み込みます