Skipping React Hook Form: What Four Real Forms Look Like Without One
Login, signup, password reset and contact all run on FormData, a Server Action and useActionState — zero form libraries in package.json. Here's the actual validation code.
React Hook Form solves a client-rendered problem: minimize re-renders with uncontrolled inputs, and validate a schema before a round trip so the user gets feedback instantly. In the Next.js App Router, a Server Action plus useActionState solves the same user-facing problem — instant feedback, no full-page reload — from the server instead, and this codebase ships four real forms (login, signup, password reset, contact) with zero form libraries anywhere in package.json.
Here's what that looks like in the actual code, where React Hook Form is still the better call, and the one security detail a client-validation library can't give you for free.
What React Hook Form is actually good at
It's a genuinely well-built library, and worth naming what it does that a hand-rolled form doesn't:
- Uncontrolled inputs by default.
register()wires a ref instead of a controlledvalue/onChangepair, so typing in one field doesn't re-render the rest of the form. On a form with dozens of fields, or one embedded inside something that re-renders often, this matters. - Resolver-based validation. Pair it with Zod, Yup, or another schema library and the same schema can validate on blur, on change, or on submit, with typed errors, before anything reaches a server.
- Cross-field, client-only validation. "Confirm password must match" or "end date must be after start date" can be checked the instant a user leaves a field, no round trip, which matters most on long multi-step forms where a server round trip per field would feel sluggish.
- A form that's part of a bigger client state machine. A multi-step wizard with a progress bar, conditional steps, and a draft saved to
localStoragebetween steps is a client-side problem before it's a validation problem, and React Hook Form composes well with that shape.
None of that is wrong to reach for. It's the right tool when the form itself is the client-side complexity — long, branching, stateful before it's ever submitted.
Why none of our four forms need it
Every form in this codebase is short (2–4 fields), submits once, and the thing that actually varies per submission — is this email taken, is this password correct, did this message pass a honeypot check — can only be answered by the server anyway. Client-side schema validation would still need a server round trip to do anything that matters; it would just add a library in front of that trip rather than replacing it.
So the actual code skips the resolver entirely and validates by hand, in the Server Action, against the raw FormData:
// src/lib/actions/auth.ts
export async function signUpWithPassword(
_prev: AuthState,
formData: FormData,
): Promise<AuthState> {
const email = String(formData.get("email") ?? "").trim().toLowerCase();
const password = String(formData.get("password") ?? "");
const consent = formData.get("consent");
if (!consent) {
return { ok: false, message: "Please accept the Terms and Privacy Policy" };
}
if (!EMAIL_RE.test(email)) {
return { ok: false, message: "Please enter a valid email address." };
}
if (password.length < MIN_PASSWORD_LENGTH) {
return {
ok: false,
message: `Password must be at least ${MIN_PASSWORD_LENGTH} characters.`,
};
}
const supabase = await createClient();
const { error } = await supabase.auth.signUp({ email, password, /* … */ });
// …
}
EMAIL_RE (src/lib/validation.ts) is a single regex, shared across every form that collects an email — no schema library, no per-field resolver config, one source of truth checked on the server where it actually has to hold.
The detail a resolver can't give you: what the error doesn't say
signInWithPassword in the same file validates the same shape of input, but its failure branch looks different on purpose:
const { error } = await supabase.auth.signInWithPassword({ email, password });
if (error) {
console.error("auth: signInWithPassword error", error);
// Always the same message — never let a caller distinguish "wrong
// password" from "email exists but unconfirmed" (account enumeration).
return { ok: false, message: "Incorrect email or password." };
}
A client-side validator can tell a user their email is malformed. It cannot tell you whether that email is registered, because that answer lives on the server, and the moment you make a login form's error message reveal it — even inadvertently, by returning a different string for "wrong password" versus "no such account" — you've built an account-enumeration oracle. requestPasswordReset in the same file makes the identical call: it always returns the same "check your email" message whether or not the address exists. This isn't something React Hook Form's validation layer decides one way or the other; it's a server-side response-shape decision that a client library never touches, because the client never learns the real answer either way.
The client half, in ten lines
The form component itself is small precisely because it isn't managing field state — useActionState holds the last server response and a pending flag, and every input stays uncontrolled with plain name attributes:
"use client";
import { useActionState } from "react";
import { signUpWithPassword, type AuthState } from "@/lib/actions/auth";
export default function SignupForm() {
const [state, action, pending] = useActionState<AuthState, FormData>(
signUpWithPassword,
null,
);
return (
<form action={action}>
<input name="email" type="email" required autoComplete="email" />
<input name="password" type="password" required autoComplete="new-password" />
{state && !state.ok && <p role="alert">{state.message}</p>}
<button type="submit" disabled={pending}>
{pending ? "Creating account…" : "Create account"}
</button>
</form>
);
}
No register(), no controlled value props, no re-render on keystroke to manage — the browser owns every input's value until submit, exactly the re-render profile React Hook Form's uncontrolled-inputs design is built to achieve, just without a library achieving it. The full mechanics of this pattern — honeypots, rate limiting, the useFormState-versus-useActionState import gotcha — are the Server Actions contact form post's territory; this is the same shape applied to authentication instead of a mailbox.
Where the two approaches actually differ
| React Hook Form (client) | Server Action + useActionState (this codebase) | |
|---|---|---|
| Where validation runs | Client first, server should still re-check | Server only, by design |
| Feedback latency | Instant, no network | One round trip (usually imperceptible on a 2–4 field form) |
| Re-renders on keystroke | None (uncontrolled + refs) | None (uncontrolled + native inputs) |
| Bundle cost | ~9 KB + a resolver package | 0 KB — ships with React 19 |
| Works with JavaScript disabled | No — client validation and submit both need JS | Yes — <form action={fn}> posts natively |
| Cross-field client validation (e.g. "passwords match") | Built in | Would need a client onChange handler layered on top |
| Best fit | Long, branching, stateful forms | Short forms whose real answer only the server has anyway |
Mistakes and how they show up
| Mistake | Symptom | Fix |
|---|---|---|
| Trusting client validation as the only check | A crafted POST bypasses every rule the UI enforced | Re-validate every field inside the Server Action, always |
| Returning a different error for "no account" vs "wrong password" | Account enumeration — an attacker can list valid emails | One generic message for every login failure |
| Adding React Hook Form to save "two lines" of manual validation | A resolver package plus its schema library for a 3-field form | Hand-validate; a schema library earns its keep past ~6+ interdependent fields |
Controlled inputs (useState per field) without a library or a reason | Every keystroke re-renders the whole form for no benefit | Uncontrolled inputs with plain name attrs, or useActionState for the submit result |
| Client-only validation with no server re-check | Works in the browser, breaks the moment someone calls the endpoint directly | The client check is a UX nicety; the server check is the actual rule |
Frequently asked questions
Should I ever add React Hook Form to a Next.js App Router project? Yes — for a form whose complexity lives on the client before submission: a multi-step wizard, a form with heavy cross-field logic, or one embedded in a component library that isn't wired to Server Actions. None of our four forms are that shape; a settings form with a dozen interdependent fields might be.
Doesn't skipping a validation library mean writing more code per form?
A little, and it's mostly one shared regex plus a handful of if checks per action — cheaper than it sounds once the pattern exists, and every check runs where it has to run anyway (the server), so nothing here is extra work, just work that isn't duplicated on the client first.
Can I use React Hook Form's validation and still submit via a Server Action?
Yes, they're not mutually exclusive — handleSubmit can call a Server Action instead of fetch. You lose the no-JavaScript progressive-enhancement guarantee either way, since RHF's onSubmit handler requires JavaScript to run in the first place.
What about useFormStatus versus useActionState?
useActionState (from react) gives you the last returned state, the action to bind to <form action>, and a pending flag together. useFormStatus (from react-dom) only gives pending status, and only to a component nested inside the form — useful for a submit button component that doesn't have direct access to the action's state.
Templates with real forms in this shape
ASoc Blueprint is an app-development agency site whose project-pricing and contact funnel run on exactly this kind of short, server-validated form. ASoc Brief is a single-page portfolio site with a contact form as its one interactive element. ASoc Byte is an IT/startup engineering-studio site built around service inquiries — another short-form, server-validated funnel.
Browse the full set of Next.js landing page templates or Tailwind landing page templates.
