How to Add Spam Protection to Next.js Forms (Without CAPTCHA)
Learn 3 effective spam detection techniques for Next.js forms: honeypot fields, timing analysis, and server-side validation. Includes React Hook Form examples and API route implementation.
Web forms attract spam. Whether it’s contact forms, newsletter signups, or user registration, bad actors and bots continuously probe your endpoints looking for ways to inject unwanted content.
Most developers reach for CAPTCHA solutions like Google reCAPTCHA or Cloudflare Turnstile. But CAPTCHAs frustrate legitimate users, harm accessibility, and increasingly get bypassed by human-solving services. There’s a better approach: layered spam detection that catches most spam without annoying your users.
In this tutorial, we’ll build progressively more sophisticated spam protection for Next.js forms using three core techniques: honeypot fields, timing analysis, and server-side validation. You’ll see exactly how each works and how to combine them into a robust defense.
The Problem with CAPTCHA
Before we dive in, let’s understand why CAPTCHA alone isn’t sufficient:
- User friction: Even simple image verification reduces form completion rates
- Accessibility issues: Audio CAPTCHAs aren’t always reliable for visually impaired users
- Bypass services: Services like 2Captcha and AntiCaptcha can solve most CAPTCHAs for $0.50-$2, making CAPTCHA worthless for low-value spam
- Doesn’t analyze content: CAPTCHA proves you’re human but says nothing about whether your submission is actually spam
The solution? Multiple lightweight checks that work together.
Technique 1: Honeypot Fields
A honeypot field is an invisible form field that humans won’t fill but bots will. It’s the simplest and most effective first-line defense.
// components/contact-form.tsx
'use client';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
interface FormData {
email: string;
name: string;
message: string;
website?: string; // honeypot field
}
export function ContactForm() {
const { register, handleSubmit, formState: { isSubmitting } } = useForm<FormData>();
const [status, setStatus] = useState<'idle' | 'success' | 'error'>('idle');
async function onSubmit(data: FormData) {
// Check honeypot - if filled, silently fail
if (data.website) {
// Return fake success to avoid revealing the honeypot
setStatus('success');
return;
}
try {
const response = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (response.ok) {
setStatus('success');
} else {
setStatus('error');
}
} catch (error) {
setStatus('error');
}
}
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div>
<label htmlFor="name" className="block text-sm font-medium">
Name
</label>
<input
id="name"
{...register('name', { required: true })}
className="w-full border rounded px-3 py-2"
/>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium">
Email
</label>
<input
id="email"
type="email"
{...register('email', { required: true })}
className="w-full border rounded px-3 py-2"
/>
</div>
<div>
<label htmlFor="message" className="block text-sm font-medium">
Message
</label>
<textarea
id="message"
{...register('message', { required: true })}
rows={5}
className="w-full border rounded px-3 py-2"
/>
</div>
{/* Honeypot field - hidden from humans */}
<div className="hidden">
<label htmlFor="website">Website</label>
<input
id="website"
{...register('website')}
tabIndex={-1}
autoComplete="off"
/>
</div>
<button
type="submit"
disabled={isSubmitting}
className="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 disabled:opacity-50"
>
{isSubmitting ? 'Sending...' : 'Send Message'}
</button>
{status === 'success' && (
<p className="text-green-600">Thank you for your message!</p>
)}
{status === 'error' && (
<p className="text-red-600">Something went wrong. Please try again.</p>
)}
</form>
);
}
Key points about honeypots:
- Use
className="hidden"ordisplay: noneto hide the field - Set
tabIndex={-1}so keyboard users can’t accidentally focus it - Never validate or require the field on the client
- If it’s filled, treat the submission as spam
- Return fake success to avoid revealing the defense
Technique 2: Timing Analysis
Bots fill forms instantly. Real humans take time to read and compose a response. By measuring how long a form stayed open, you can filter out rapid-fire submissions.
// components/smart-form.tsx
'use client';
import { useState, useEffect, useRef } from 'react';
import { useForm } from 'react-hook-form';
interface FormData {
email: string;
name: string;
message: string;
}
interface FormMetadata {
timeOnPage: number;
submittedAt: number;
userAgent: string;
}
export function SmartForm() {
const { register, handleSubmit } = useForm<FormData>();
const formLoadedAt = useRef<number>(Date.now());
const [isSubmitting, setIsSubmitting] = useState(false);
async function onSubmit(data: FormData) {
const timeOnPage = Date.now() - formLoadedAt.current;
const metadata: FormMetadata = {
timeOnPage,
submittedAt: Date.now(),
userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : '',
};
try {
const response = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...data, metadata }),
});
if (response.ok) {
// Handle success
}
} catch (error) {
console.error('Form submission error:', error);
} finally {
setIsSubmitting(false);
}
}
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<input {...register('name')} placeholder="Name" />
<input {...register('email')} type="email" placeholder="Email" />
<textarea {...register('message')} placeholder="Message" rows={5} />
<button type="submit" disabled={isSubmitting}>
Submit
</button>
</form>
);
}
On the server, check that the form took at least a few seconds to complete:
// api/contact.ts route snippet
const MIN_TIME_ON_PAGE = 3000; // 3 seconds
if (metadata.timeOnPage < MIN_TIME_ON_PAGE) {
// Likely a bot - return fake success
return NextResponse.json({ success: true }, { status: 200 });
}
This approach catches:
- Automated bots that fill forms instantly
- Script-based submission attempts
- Replay attacks using captured form data
Real users will always exceed this threshold naturally.
Technique 3: Server-Side Validation
Client-side checks are easily bypassed. The real defense happens on your server using an API route that validates submissions against multiple signals.
// app/api/contact/route.ts
import { NextRequest, NextResponse } from 'next/server';
interface ContactFormData {
email: string;
name: string;
message: string;
metadata: {
timeOnPage: number;
submittedAt: number;
userAgent: string;
};
}
interface ValidationResult {
isSpam: boolean;
score: number;
reasons: string[];
}
async function validateSubmission(data: ContactFormData): Promise<ValidationResult> {
const reasons: string[] = [];
let score = 0;
// Check 1: Email format and common spam patterns
if (!isValidEmail(data.email)) {
reasons.push('invalid_email');
score += 3;
}
if (isDisposableEmail(data.email)) {
reasons.push('disposable_email');
score += 2;
}
// Check 2: Timing analysis
if (data.metadata.timeOnPage < 3000) {
reasons.push('too_fast');
score += 2;
}
// Check 3: Content analysis
if (containsSpamKeywords(data.message)) {
reasons.push('spam_keywords');
score += 3;
}
if (hasTooManyLinks(data.message)) {
reasons.push('excessive_links');
score += 2;
}
// Check 4: Rate limiting by IP
const clientIp = getClientIp(data.metadata.userAgent);
const recentSubmissions = await countRecentSubmissions(clientIp);
if (recentSubmissions > 5) {
reasons.push('rate_limit_exceeded');
score += 3;
}
return {
isSpam: score >= 4,
score,
reasons,
};
}
function isValidEmail(email: string): boolean {
const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return regex.test(email);
}
function isDisposableEmail(email: string): boolean {
// List of common disposable email domains
const disposableDomains = new Set([
'tempmail.com',
'guerrillamail.com',
'mailinator.com',
'10minutemail.com',
]);
const domain = email.split('@')[1]?.toLowerCase();
return disposableDomains.has(domain || '');
}
function containsSpamKeywords(text: string): boolean {
const spamKeywords = [
'viagra',
'casino',
'lottery',
'click here',
];
const lowerText = text.toLowerCase();
return spamKeywords.some(keyword => lowerText.includes(keyword));
}
function hasTooManyLinks(text: string): boolean {
const linkRegex = /https?:\/\/[^\s]+/gi;
const links = text.match(linkRegex) || [];
return links.length > 3;
}
function getClientIp(userAgent: string): string {
// In production, use X-Forwarded-For header or similar
return 'unknown';
}
async function countRecentSubmissions(ip: string): Promise<number> {
// Query your database for submissions from this IP in the last hour
// This is a placeholder - implement with your actual database
return 0;
}
export async function POST(request: NextRequest) {
try {
const data: ContactFormData = await request.json();
// Validate the submission
const validation = await validateSubmission(data);
if (validation.isSpam) {
// Return fake success to avoid revealing our detection
return NextResponse.json({ success: true }, { status: 200 });
}
// Process legitimate submission
// Send email, save to database, etc.
console.log('Processing submission:', data);
return NextResponse.json(
{ success: true },
{ status: 200 }
);
} catch (error) {
console.error('Contact form error:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
This server-side route:
- Validates email format
- Detects disposable email addresses
- Checks for spam keywords and excessive links
- Implements rate limiting per IP
- Returns fake success for spam to avoid revealing detection
Combining All Three Techniques
The power comes from layering these defenses. A submission must pass all checks:
Submission received
↓
[Honeypot check] ← Catches automated bots
↓ (pass)
[Timing check] ← Catches rapid submissions
↓ (pass)
[Content/email/rate-limit checks] ← Catches known patterns
↓ (pass)
Process submission
This multi-layered approach is far more effective than any single technique.
Monitoring and Feedback
One critical practice: log spam submissions and analyze patterns. Over time, you’ll discover new spam signals specific to your application.
async function logSpamSubmission(
data: ContactFormData,
validation: ValidationResult
) {
// Store in database with timestamp
await db.spamLog.create({
email: data.email,
score: validation.score,
reasons: validation.reasons,
content: data.message.substring(0, 200),
createdAt: new Date(),
});
}
The Turnkey Solution: FormShield
While these techniques work well, managing them at scale gets complex. You need to:
- Maintain email disposability lists (they change constantly)
- Track IP reputation data
- Analyze content using ML models
- Monitor false positive rates
- Adjust thresholds as spam evolves
This is where FormShield comes in. The FormShield API combines all these signals plus additional intelligence from our spam database:
import { checkForm } from '@formshield/next';
export async function POST(request: NextRequest) {
const data = await request.json();
const result = await checkForm({
email: data.email,
name: data.name,
content: data.message,
ip: request.ip,
metadata: {
formLoadedAt: data.metadata.submittedAt - data.metadata.timeOnPage,
},
});
if (result.action === 'block') {
return NextResponse.json({ success: true }, { status: 200 });
}
// Process submission
}
FormShield handles:
- Real-time IP intelligence (VPN/datacenter detection, threat scoring)
- Email validation with disposable detection
- AI content analysis using machine learning models
- Behavioral analysis (timing, patterns, bot detection)
- A detailed breakdown showing exactly why something was flagged
You get spam detection that improves over time as our database grows, with no CAPTCHA friction.
Summary
Effective spam protection requires multiple layers:
- Honeypot fields catch basic bots with zero user friction
- Timing analysis filters out automated submissions
- Server-side validation checks email, content, and rate limits
- API-based solutions like FormShield add IP intelligence and ML analysis
Start with honeypots and timing. Add the validation checks above. For production systems handling high volume, integrate a specialized spam detection API.
The goal isn’t perfection—it’s making spam economically unfeasible while keeping legitimate users happy.