React honeypot field for spam-resistant forms.
Block common form bots with a hidden input trap and time-threshold validation.
TypeScript-first, zero runtime dependencies, and ready for Next.js, Remix, Vite,
React Hook Form, Formik, Express, and Hono.
npm install react-honeypot-field
react-honeypot-field is a small React anti-spam package for contact forms,
lead forms, signup forms, and other low-friction form flows where a CAPTCHA
would be too heavy.
useHoneypot() and validateHoneypot().For high-value or abuse-prone forms, use this with rate limiting, IP reputation, email verification, or a CAPTCHA fallback.
"use client";
import type { FormEvent } from "react";
import { HoneypotField, useHoneypot } from "react-honeypot-field";
export function ContactForm() {
const { fieldProps, validate, mountedAt } = useHoneypot({
fieldName: "website",
timeThreshold: 1500,
});
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
const hp = validate();
if (!hp.ok) {
// Silent drop: do not tell the bot which check failed.
return;
}
const form = new FormData(e.currentTarget);
const payload = {
name: String(form.get("name") ?? ""),
email: String(form.get("email") ?? ""),
message: String(form.get("message") ?? ""),
website: String(form.get(fieldProps.name) ?? ""),
_mountedAt: mountedAt,
};
await fetch("/api/contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
}
return (
<form onSubmit={handleSubmit}>
<HoneypotField {...fieldProps} />
<label>
Name
<input name="name" autoComplete="name" required />
</label>
<label>
Email
<input name="email" type="email" autoComplete="email" required />
</label>
<label>
Message
<textarea name="message" required />
</label>
<button type="submit">Send</button>
</form>
);
}
// app/api/contact/route.ts (Next.js App Router)
import { validateHoneypot } from "react-honeypot-field/validate";
export async function POST(req: Request) {
const body = (await req.json()) as Record<string, unknown>;
const hp = validateHoneypot({
fieldValue: typeof body.website === "string" ? body.website : "",
mountedAt: typeof body._mountedAt === "number" ? body._mountedAt : null,
submittedAt: Date.now(),
});
if (!hp.ok) {
// Return success so bots cannot learn how they were detected.
return Response.json({ ok: true });
}
// Process the real form submission.
return Response.json({ ok: true });
}
Bots that blindly fill every form field will fill the hidden honeypot input. Bots that submit instantly will fail the time-threshold check. Humans never see the field and usually need more than 1.5 seconds to read and submit a real form.
| Technique | How | Why |
|---|---|---|
| Hidden field | Off-screen with position: absolute; left: -9999px |
Some bots skip display:none fields, but still fill positioned fields. |
| Time threshold | Tracks the form mount timestamp and validates elapsed time on submit | Automated submissions often arrive faster than a human can read and type. |
<HoneypotField />Renders an off-screen text input. Spread fieldProps from useHoneypot() onto
it.
import { HoneypotField } from "react-honeypot-field";
<HoneypotField name="website" label="Do not fill this field" tabIndex={-1} />;
| Prop | Type | Default | Description |
|---|---|---|---|
name |
string |
"website" |
Field name. Avoid obvious names like "honeypot", "trap", or "bot". |
label |
string |
"Do not fill this field" |
Text for the <label> element. |
tabIndex |
number |
-1 |
Keeps the field out of keyboard tab order. |
All other <input> attributes are forwarded to the underlying element.
useHoneypot(options?)import { useHoneypot } from "react-honeypot-field";
const { fieldProps, validate, mountedAt } = useHoneypot({
fieldName: "website",
timeThreshold: 1500,
});
Returns:
| Key | Type | Description |
|---|---|---|
fieldProps |
{ ref, name } |
Spread onto <HoneypotField />. |
validate() |
() => HoneypotResult |
Call before submitting. Returns { ok: true } or { ok: false, reason }. |
mountedAt |
number |
Unix timestamp in milliseconds when the hook mounted. Send to the server for the time-threshold check. |
HoneypotResult:
type HoneypotResult =
| { ok: true }
| { ok: false; reason: "honeypot_filled" | "submitted_too_fast" };
validateHoneypot(options)Server-side validation is available from a separate entry point so API routes, server actions, and Node handlers do not bundle React.
import { validateHoneypot } from "react-honeypot-field/validate";
const result = validateHoneypot({
fieldValue: body.website,
mountedAt: body._mountedAt,
submittedAt: Date.now(),
timeThreshold: 1500,
});
if (!result.ok) {
// result.reason:
// "honeypot_filled" | "submitted_too_fast" | "missing_timestamp"
}
| Option | Type | Default | Description |
|---|---|---|---|
fieldValue |
string | null | undefined |
Required | Honeypot field value from the submitted form. |
mountedAt |
number | null |
undefined |
Client mount timestamp from useHoneypot(). |
submittedAt |
number | null |
undefined |
Server submit timestamp, usually Date.now(). |
timeThreshold |
number |
1500 |
Minimum elapsed milliseconds required for a valid submission. |
const formRef = useRef<HTMLFormElement>(null);
const { register, handleSubmit } = useForm<ContactValues>();
const { fieldProps, validate, mountedAt } = useHoneypot();
const onSubmit = handleSubmit((data) => {
if (!validate().ok) return;
const form = formRef.current ? new FormData(formRef.current) : null;
const website = String(form?.get(fieldProps.name) ?? "");
return fetch("/api/contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...data, website, _mountedAt: mountedAt }),
});
});
<form ref={formRef} onSubmit={onSubmit}>
<HoneypotField {...fieldProps} />
<input {...register("email")} />
</form>;
const formRef = useRef<HTMLFormElement>(null);
const { fieldProps, validate, mountedAt } = useHoneypot();
<Formik
initialValues={{ email: "", message: "" }}
onSubmit={(values, { setSubmitting }) => {
if (!validate().ok) {
setSubmitting(false);
return;
}
const form = formRef.current ? new FormData(formRef.current) : null;
const website = String(form?.get(fieldProps.name) ?? "");
return fetch("/api/contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ...values, website, _mountedAt: mountedAt }),
});
}}
>
{({ handleSubmit }) => (
<form ref={formRef} onSubmit={handleSubmit}>
<HoneypotField {...fieldProps} />
<Field name="email" type="email" />
</form>
)}
</Formik>;
import { validateHoneypot } from "react-honeypot-field/validate";
app.post("/contact", async (req, res) => {
const hp = validateHoneypot({
fieldValue: req.body.website,
mountedAt: req.body._mountedAt,
submittedAt: Date.now(),
});
if (!hp.ok) return res.json({ ok: true });
// Process the real form submission.
});
Honeypot spam protection is a heuristic, not a hard security boundary. A sophisticated bot can bypass these checks by detecting off-screen elements, leaving them empty, or waiting before submitting.
Use react-honeypot-field as a low-friction first layer. For high-risk forms,
combine it with server-side rate limiting, abuse monitoring, IP controls, email
verification, or a CAPTCHA challenge.
See SECURITY.md for the full security model.
display:none?Some crawlers and bots detect and skip fields with display:none or
visibility:hidden. Positioning the field off-screen keeps it present in the
DOM and styled, which catches less sophisticated bots.
"honeypot"?Naive bots skip fields named "honeypot", "trap", "antispam", or similar.
The default "website" is plausible because many real forms include a website
field.
A human usually needs at least 1 to 2 seconds to read a form label and start
typing. 1500 milliseconds catches many automated submissions while staying out
of the way for normal users. Adjust timeThreshold if your form is unusually
short.
Throwing on validation failure means you need try/catch in your submit
handler. A tagged union ({ ok: true } | { ok: false; reason }) works with type
narrowing and keeps submit handlers explicit.
MIT - Oleg Koval