protectSignup.How do you protect a React Hook Form from spam?
Combine client and server validation, rate limiting, bot detection, and email verification. No single layer is enough. Browser checks improve UX and are trivial to skip. A User-Agent test is trivial to spoof. An in-memory counter resets on every serverless cold start. Put the same schema on both sides, then enforce bots, velocity, and mailbox quality in the route handler.
This walkthrough uses React Hook Form 7.86.0, Next.js 16.3.1, Zod 4.4.3, @hookform/resolvers 5.9.1, and @arcjet/next 1.10.0. The same four layers apply to any framework. For more information about why a puzzle is the wrong foundation, see CAPTCHA alternatives. For more information about classifying automated clients, see bot detection techniques.
What are the four layers of form protection?
Use this checklist on every signup, waitlist, or lead form:
- Validate fields on the client and the server. Share one Zod schema. Client validation gives immediate feedback. Server validation is the security boundary.
- Rate limit submissions per identity. A real user submits once, maybe a few times after an error. Five posts in ten minutes from one IP is a generous ceiling for a signup form.
- Detect bots on the POST route. Attackers automate across many forms. An empty bot allow list denies every detected bot on that handler.
- Verify the email, not only its syntax. Reject disposable domains, invalid addresses, and domains with no MX records. Syntax-only checks accept
not-a-mailbox@example.invalid.
Skip a layer and the others leak. A valid-looking address from a headless script at 50 attempts per minute will pass Zod and fail everything else. A human on a disposable inbox will pass bots and fail email verification.
How do you validate fields on the client and the server?
Validation raises the cost of garbage payloads and improves error display. It does not stop a determined script. Anything that runs only in the browser can be bypassed with curl.
Define the schema once and import it from the client component and the route.
import { z } from "zod";
export const formSchema = z.object({ email: z.email({ error: "Please enter a valid email address.", }),});
export type FormValues = z.infer<typeof formSchema>;Zod 4 exposes z.email() as a top-level string format. Keep the schema strict enough to catch typos and loose enough to accept real international addresses. Email syntax is defined across multiple RFCs. Do not invent a narrower regex unless you have a product reason.
Wire the schema into React Hook Form with zodResolver:
"use client";
import { zodResolver } from "@hookform/resolvers/zod";import { useForm } from "react-hook-form";import { formSchema, type FormValues } from "@/lib/form-schema";
export function EmailForm() { const form = useForm<FormValues>({ resolver: zodResolver(formSchema), defaultValues: { email: "" }, });
async function onSubmit(values: FormValues) { const result = await fetch("/api/submit", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(values), });
if (!result.ok) { const error = await result.json().catch(() => ({})); form.setError("root", { message: error.message ?? "We could not sign you up.", }); return; } }
return ( <form onSubmit={form.handleSubmit(onSubmit)}> <label htmlFor="email">Email</label> <input id="email" type="email" autoComplete="email" {...form.register("email")} /> {form.formState.errors.email && ( <p>{form.formState.errors.email.message}</p> )} {form.formState.errors.root && ( <p>{form.formState.errors.root.message}</p> )} <button type="submit">Sign up</button> </form> );}type="email" adds native browser checks. Those checks help real users and do not stop attackers. In the route, parse the body with the same schema before you call any security rule:
const json = await req.json();const parsed = formSchema.safeParse(json);if (!parsed.success) { return Response.json({ message: "Invalid request" }, { status: 400 });}How do you rate limit form submissions?
Most legitimate users submit once. A handful of retries after a typo is normal. Dozens of posts from one address in a few minutes is not.
An in-process LRU keyed on IP is a common first attempt. It fails in two ways. Serverless instances recycle, so the counter resets. Attackers rotate IPs, so a single-address ceiling never fills. A hosted Redis limiter fixes persistence and adds an extra system to run.
Prefer a sliding window on the form POST, keyed on a stable identity when you have one and on IP for anonymous signup. Five requests per ten minutes is a reasonable default for email capture. For more information about why sliding windows beat naive fixed windows here, see the rate limiting guide.
protectSignup includes that window so you do not maintain Redis yourself. The interval and max options below are the same policy.
How do you block bots on the submit route?
Attackers automate because they hit many forms. A User-Agent library such as isbot catches default curl and python-urllib strings. Clients change the header in one line, so treat that check as hygiene.
Bot detection on the POST should deny every detected bot. Signup is not a search-index surface. Configure bots: { mode: "LIVE", allow: [] }. That is an allow list with nothing on it, which blocks all detected bots. Do not use a block list.
If you later allow a monitoring crawler on the marketing site, keep the empty allow list on this route. Preview bots (Slack, Discord) do not need to POST a signup form.
How do you verify email addresses?
Syntax says the string looks like an email. It does not say the mailbox can receive mail, or that you want that mailbox.
Check at least three things server-side:
- INVALID: the address is not a well-formed email.
- DISPOSABLE: the domain is a throwaway provider used for one-time signups.
NO_MX_RECORDS: the domain cannot receive mail.
Those results are inputs to a product decision. You can deny immediately, or accept the row and flag it for review. Revealing "we blocked disposable email" in the JSON helps a spammer learn your rules. A generic 400 with a human-readable hint on the field is usually enough.
protectSignup runs these checks with the bot and rate-limit rules in one protect() call. Pass the parsed email into aj.protect(req, { email }).
How do you combine the layers with protectSignup?
protectSignup is the v1 bundle: email deny list, bot allow list, and a sliding-window rate limit. Pin @arcjet/next to 1.10.0. Branch on isDenied(), then isEmail() and isRateLimit().
import arcjet, { protectSignup } from "@arcjet/next";import { formSchema } from "@/lib/form-schema";
const aj = arcjet({ key: process.env.ARCJET_KEY!, rules: [ protectSignup({ email: { mode: "LIVE", deny: ["DISPOSABLE", "NO_MX_RECORDS", "INVALID"], }, bots: { mode: "LIVE", allow: [] }, rateLimit: { mode: "LIVE", interval: "10m", max: 5 }, }), ],});
export async function POST(req: Request) { const parsed = formSchema.safeParse(await req.json()); if (!parsed.success) { return Response.json({ message: "Invalid request" }, { status: 400 }); }
const { email } = parsed.data; const decision = await aj.protect(req, { email });
if (decision.isDenied()) { if (decision.reason.isEmail()) { return Response.json({ message: "Invalid email" }, { status: 400 }); } if (decision.reason.isRateLimit()) { return Response.json( { message: "Too many requests. Try again later." }, { status: 429 }, ); } return Response.json({ message: "Forbidden" }, { status: 403 }); }
return Response.json({ ok: true });}Test the rule with mode: "DRY_RUN" on each of the three inner options first. You still receive the decision and can log decision.reason without blocking users. Promote to LIVE when the false-positive rate is acceptable.
Do not put a CAPTCHA in front of this form unless the other four layers still leave a human-shaped hole and you have an accessible fallback. The handler above already constrains automation, velocity, and mailbox quality. That is the control. The widget is optional friction.
Which versions should you pin?
The following table lists the versions this article was written against. Upgrade on purpose. Schema and SDK option names change across majors.
| Package | Version | Role |
|---|---|---|
react-hook-form | 7.86.0 | Client form state and field errors |
next | 16.3.1 | App Router and the POST handler |
zod | 4.4.3 | Shared client and server schema |
@hookform/resolvers | 5.9.1 | Connects Zod 4 to React Hook Form |
@arcjet/next | 1.10.0 |
|
Frequently asked questions
How do you stop spam on a React Hook Form?
Validate on the client and server with one schema, rate-limit the POST, deny detected bots, and verify the email is not disposable, invalid, or missing MX records.
Is client-side Zod enough?
No. Browser checks are UX. Attackers POST JSON directly. Parse the same schema in the route before you persist anything.
What does `protectSignup` do in v1?
It bundles email checks (deny: ["DISPOSABLE", "NO_MX_RECORDS", "INVALID"]), bot detection (allow: []), and a sliding-window rate limit. Branch on isDenied(), isEmail(), and isRateLimit().
Why not an in-memory IP limiter?
Serverless instances recycle and reset the counter. Attackers rotate IPs. Use a persistent, identity-aware window.
Should the signup route allow search engines?
No. Use an empty bot allow list on the form POST. Search crawlers do not need to submit email capture.
Which versions does this guide pin?
React Hook Form 7.86.0, Next.js 16.3.1, Zod 4.4.3, @hookform/resolvers 5.9.1, and @arcjet/next 1.10.0.
Application security in your code
Protect your application with Arcjet
Get rate limits, bot detection, and attack blocking in your request handlers.