The contact form on this site fires a Telegram notification whenever someone submits. When I shipped it I assumed I'd need reCAPTCHA — every spam-protection guide on the internet says so. I tried it for a week, hated the friction, and ripped it out.
Two months later, with reCAPTCHA gone, the inbox is still clean. Here's the pattern.
The whole filter, in code
// app/api/telegram/route.ts
const data = await request.json()
const { website, mountedAt, name, email, message } = data
// 1. Honeypot — real users never see this field
if (website && website.trim().length > 0) {
return NextResponse.json({ success: true }) // silent accept
}
// 2. Time-floor — bots submit instantly, humans need to type
if (typeof mountedAt === 'number' && Date.now() - mountedAt < 1500) {
return NextResponse.json({ success: true })
}
// 3. Size limits — protect downstream APIs from payload abuse
if (name.length > 200 || email.length > 200 || message.length > 5000) {
return NextResponse.json({ error: 'Payload too large' }, { status: 413 })
}
That's it. Three guards, all server-side. No client script.
Why each one matters
The honeypot is a hidden form field — <input name="website" tabindex="-1" autocomplete="off" style={{display:'none'}} /> on the React side. Real browsers don't render it, real users don't fill it. Most form-spamming bots crawl the DOM, see an input, and dutifully fill every one. The moment website has any value, you know it's a bot. Critical detail: don't return an error. Return { success: true }. Bots watching for 4xx/5xx will retry; bots that get a 200 mark the form as "done" and move on.
The time-floor catches the rest. The form ships its mount timestamp into a hidden field on render. When the submission arrives, anything under 1.5 seconds between mount and submit is a bot — humans physically cannot type a name, email, and message that fast. Same trick: return 200 silently so the bot doesn't learn to back off.
The size limits aren't really anti-spam — they're abuse protection. Without them, a bored attacker could POST a 50MB body and run up your Telegram API quota. Cap each field to a sane max and return 413 for oversize.
Why no reCAPTCHA
reCAPTCHA solves a real problem in the wrong way for this use case. It:
- Loads ~100KB of third-party JavaScript on every page — most of which is fingerprinting your visitors so Google can score them later.
- Adds a privacy disclosure to the page — you legally need a footnote about Google tracking, which is exactly the wrong tone for "send me a message."
- Friction: even invisible reCAPTCHA fails for some users on VPNs, in private mode, or on Linux/Firefox. Those users — disproportionately developers, your prospects — see a checkbox or worse, a puzzle. Some of them give up.
For a low-volume B2B contact form, the math just doesn't work. The cost of reCAPTCHA (lost legitimate submissions) is higher than the benefit (filtering spam that the honeypot catches anyway).
When this stops working
I'd revisit the moment any of these is true:
- Targeted attacks, not generic spam — a human writing custom code to bypass your specific filter. The four-line filter is for the scraper that crawls 100K forms a day, not for someone trying to spam you specifically.
- High-volume forms — newsletter signups, sign-up flows, anything with serious money behind it. There the false-negative cost is high enough to warrant a smarter system (Cloudflare Turnstile, Friendly Captcha, or proper rate limiting backed by Redis).
- Authenticated abuse — once spam comes from logged-in accounts, the form filter is the wrong layer. Move it to account creation.
For everything in between — a portfolio, a small SaaS contact page, a "tell me more" CTA — start with the four-line version. Add complexity only if it actually breaks.
The full Reactful client-side bit
For completeness, the React side that powers the time-floor and honeypot:
const [mountedAt] = useState(() => Date.now())
return (
<form onSubmit={handleSubmit}>
{/* honeypot — visually + semantically hidden */}
<input
type="text"
name="website"
tabIndex={-1}
autoComplete="off"
aria-hidden="true"
className="absolute left-[-9999px]"
/>
<input type="hidden" name="mountedAt" value={mountedAt} />
{/* real fields */}
<input name="name" required />
<input name="email" type="email" required />
<textarea name="message" required />
<button type="submit">Send</button>
</form>
)
Three things to get right:
- Don't use
display: noneon the honeypot. Some bots check computed style and skip it. Use absolute positioning off-screen — looks empty to humans, looks like a real field to crawlers. - Mount timestamp is per-render, not per-session. If you cache it in localStorage you'll defeat the time-floor for legitimate returning users.
aria-hiddenkeeps screen readers from announcing the honeypot. They'd skip adisplay:nonefield anyway, but absolute-positioned inputs withoutaria-hiddencan confuse assistive tech.
The lesson, if there is one: spam protection is a UX problem first. The "right" answer in the abstract — the heaviest, smartest filter — is often the wrong answer once you account for the cost it imposes on legitimate users. For a contact form on a personal site, four lines of server-side check is enough. Save the captchas for the threats that actually warrant them.