How to stop form spam with a honeypot field
Public contact forms attract spam bots. Most of them are dumb: they load your page, find every input, fill in all of them, and submit. A honeypot turns that behavior against them. You add a field that real users never see or touch, so if it comes back filled in, you know a bot did it. No CAPTCHA, no third-party service, no friction for real people.
Here’s exactly how I wired one into my contact form.
Step 1: add the hidden field to the schema
My Zod schema (which is shared by the client and server) includes a company field. The
rule is “must be empty”:
// src/lib/contact-schema.ts
// Honeypot — real users leave this empty; bots tend to fill every field.
company: z.string().max(0).optional().or(z.literal("")),z.string().max(0) means a string of length zero. The .optional().or(z.literal(""))
part lets it also be missing or an explicit empty string, so a legitimate
submission that simply omits the field still passes.
Step 2: render a genuinely hidden input
The trick is hiding the field from humans and assistive tech without using
type="hidden" — some bots specifically skip hidden inputs, so I want a normal
text input that’s just positioned off-screen:
// src/components/portfolio/ContactForm.tsx
{/* Honeypot — visually hidden, off the tab order, ignored by users. */}
<div
aria-hidden="true"
className="absolute -left-[9999px] h-0 w-0 overflow-hidden"
>
<label htmlFor="cf-company">Company</label>
<input
id="cf-company"
type="text"
tabIndex={-1}
autoComplete="off"
{...register("company")}
/>
</div>Every attribute here has a job:
absolute -left-[9999px]pushes the whole block far off the left edge of the screen, so no sighted user ever sees it.aria-hidden="true"hides it from screen readers, so blind users are never told about a field they shouldn’t fill.tabIndex={-1}removes it from the keyboard tab order, so a keyboard user can’t accidentally land on it.autoComplete="off"stops the browser from helpfully auto-filling it with a saved company name — which would trip the trap on a real user.
To a scraping bot, though, it’s just another text input in the DOM, and it fills it in.
Step 3: handle the trap on the server
The server validates with the same schema, so a filled honeypot already fails
validation and returns a 400. But there’s a subtler move for a truly empty-but-
present value, and the important lesson is what the handler does when it sees
company has content:
// api/contact.ts
const { name, email, message, company } = parsed.data
// Honeypot tripped — respond 200 so bots get no signal, but send nothing.
if (company) return res.status(200).json({ ok: true })The key idea is the silent 200. When the honeypot is tripped, I don’t return
an error. I return 200 OK with { ok: true } — the exact same response a real
success gives — but I never send the email. The bot thinks it succeeded and
moves on, so it never learns to adapt. If I returned a 400, a smarter bot
could detect the block and retry differently.
flowchart TD
A[Submission arrives] --> B{company field filled?}
B -- No --> C[Send the email → 200 ok]
B -- Yes --> D[Send nothing → 200 ok]Both branches look identical from the outside. That’s the whole point.
A caveat
A honeypot stops the lazy majority of bots, not a determined attacker who reads your HTML. Treat it as a cheap first layer. On this form it sits alongside server-side schema validation and in-memory rate limiting — defence in depth, where each layer catches what the others miss.
What to remember
- A honeypot is a hidden field humans leave empty and bots fill in.
- Hide it off-screen with
aria-hidden,tabIndex={-1}, andautoComplete="off"— nottype="hidden". - Validate it as “must be empty” in your shared schema.
- On a tripped honeypot, return a normal
200and quietly do nothing, so bots get no feedback to learn from. - It’s one layer, not the whole defence.
Related reading: the visually-hidden techniques here overlap with the accessibility patterns in building an accessible form with react-hook-form and Zod.