Back to Blog
Nov 28, 2025 15 min read FormShield Team

React Hook Form Spam Protection: Complete Implementation Guide

Learn how to protect React Hook Form submissions from spam using honeypots, timing analysis, custom validation, and API integration. Includes complete code examples with FormShield.

react react-hook-form spam protection tutorial forms
React Hook Form with multiple spam protection layers illustration

If you’re building forms with React Hook Form, you’ve probably noticed that spam submissions start rolling in the moment you deploy. Contact forms, signup pages, feedback widgets—bots find them all. And the annoying part? Traditional CAPTCHAs don’t work as well as you’d hope (more on that later).

This guide walks through every spam protection technique you can use with React Hook Form, from simple honeypots to timing analysis to integrating external APIs. By the end, you’ll have a complete picture of how to layer these defenses together.

Why React Hook Form Needs Extra Protection

React Hook Form is great for building performant forms with minimal re-renders. But it doesn’t come with built-in spam protection—that’s on you. And since it uses uncontrolled components under the hood, traditional validation libraries don’t always help with spam detection.

The common approach is slapping a CAPTCHA on your form. But here’s the thing: CAPTCHA has problems.

  • User friction: Completion rates drop when users have to identify traffic lights
  • Accessibility: Screen readers struggle with image-based challenges
  • Bypass services: For $0.50-2.00, services like 2Captcha will solve your CAPTCHA for bots
  • Content-blind: CAPTCHA proves someone is human, not that their message isn’t spam

You need a different approach—one that catches spam without bothering legitimate users.

Technique 1: Honeypot Fields

The simplest and most effective first defense is the honeypot. It’s a hidden form field that real users never see or fill, but bots blindly populate.

Here’s how to implement it with React Hook Form:

// components/contact-form.tsx
'use client';

import { useForm } from 'react-hook-form';
import { useState } from 'react';

interface ContactFormData {
  name: string;
  email: string;
  message: string;
  company?: string; // honeypot - named to look legitimate
}

export function ContactForm() {
  const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm<ContactFormData>();
  const [submitted, setSubmitted] = useState(false);

  async function onSubmit(data: ContactFormData) {
    // Honeypot check - if filled, treat as spam
    if (data.company) {
      // Show fake success to avoid revealing our defense
      setSubmitted(true);
      return;
    }

    const response = await fetch('/api/contact', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        name: data.name,
        email: data.email,
        message: data.message,
      }),
    });

    if (response.ok) {
      setSubmitted(true);
    }
  }

  if (submitted) {
    return <p className="text-green-600">Thanks! We'll be in touch.</p>;
  }

  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: 'Name is required' })}
          className="w-full border rounded px-3 py-2"
        />
        {errors.name && (
          <p className="text-red-500 text-sm">{errors.name.message}</p>
        )}
      </div>

      <div>
        <label htmlFor="email" className="block text-sm font-medium">
          Email
        </label>
        <input
          id="email"
          type="email"
          {...register('email', {
            required: 'Email is required',
            pattern: {
              value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
              message: 'Invalid email address',
            },
          })}
          className="w-full border rounded px-3 py-2"
        />
        {errors.email && (
          <p className="text-red-500 text-sm">{errors.email.message}</p>
        )}
      </div>

      <div>
        <label htmlFor="message" className="block text-sm font-medium">
          Message
        </label>
        <textarea
          id="message"
          rows={5}
          {...register('message', { required: 'Message is required' })}
          className="w-full border rounded px-3 py-2"
        />
        {errors.message && (
          <p className="text-red-500 text-sm">{errors.message.message}</p>
        )}
      </div>

      {/* Honeypot field - invisible to humans */}
      <div
        aria-hidden="true"
        style={{
          position: 'absolute',
          left: '-9999px',
          width: '1px',
          height: '1px',
          overflow: 'hidden',
        }}
      >
        <label htmlFor="company">Company</label>
        <input
          id="company"
          {...register('company')}
          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>
    </form>
  );
}

A few implementation notes:

  • Name the field something enticing like “company”, “website”, or “url”—bots love filling these
  • Use position: absolute instead of display: none—some bots detect hidden CSS
  • Set tabIndex={-1} so keyboard users can’t accidentally tab into it
  • Return fake success when honeypot is triggered—don’t let spammers know they got caught

This catches maybe 60-70% of basic bots. Not bad for zero user friction.

Technique 2: Timing Analysis

Bots work fast. They fill and submit forms in milliseconds. Real humans take 10+ seconds to read and respond. By measuring time-on-form, you can filter out robotic submissions.

// components/timed-form.tsx
'use client';

import { useForm } from 'react-hook-form';
import { useRef, useState } from 'react';

interface FormData {
  email: string;
  name: string;
  message: string;
}

interface FormMetadata {
  formLoadedAt: number;
  submittedAt: number;
  timeOnForm: number;
}

export function TimedContactForm() {
  const { register, handleSubmit, formState: { isSubmitting } } = useForm<FormData>();
  const formLoadedAt = useRef<number>(Date.now());
  const [status, setStatus] = useState<'idle' | 'success' | 'error'>('idle');

  async function onSubmit(data: FormData) {
    const submittedAt = Date.now();
    const timeOnForm = submittedAt - formLoadedAt.current;

    const metadata: FormMetadata = {
      formLoadedAt: formLoadedAt.current,
      submittedAt,
      timeOnForm,
    };

    // Client-side check (also verify on server)
    if (timeOnForm < 2000) {
      // Way too fast - show fake success
      setStatus('success');
      return;
    }

    try {
      const response = await fetch('/api/contact', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ ...data, metadata }),
      });

      setStatus(response.ok ? 'success' : 'error');
    } catch {
      setStatus('error');
    }
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
      <input {...register('name', { required: true })} placeholder="Name" className="w-full border rounded px-3 py-2" />
      <input {...register('email', { required: true })} type="email" placeholder="Email" className="w-full border rounded px-3 py-2" />
      <textarea {...register('message', { required: true })} placeholder="Message" rows={5} className="w-full border rounded px-3 py-2" />

      <button type="submit" disabled={isSubmitting} className="bg-blue-600 text-white px-4 py-2 rounded">
        {isSubmitting ? 'Sending...' : 'Submit'}
      </button>

      {status === 'success' && <p className="text-green-600">Message sent!</p>}
      {status === 'error' && <p className="text-red-500">Something went wrong.</p>}
    </form>
  );
}

The key here is using useRef to store the timestamp when the component mounts. This survives re-renders without causing them.

Your server should also verify this timing:

// app/api/contact/route.ts
const MIN_TIME_MS = 3000; // 3 seconds minimum
const MAX_TIME_MS = 3600000; // 1 hour maximum

export async function POST(request: NextRequest) {
  const data = await request.json();
  const { metadata } = data;

  // Timing validation
  if (metadata.timeOnForm < MIN_TIME_MS) {
    // Too fast - likely a bot
    return NextResponse.json({ success: true }); // fake success
  }

  if (metadata.timeOnForm > MAX_TIME_MS) {
    // Stale form - could be replay attack
    return NextResponse.json({ success: true }); // fake success
  }

  // Process legitimate submission...
}

Technique 3: Custom Validation Rules

React Hook Form’s validation system can double as a spam detection layer. By adding custom validate functions, you can check for common spam patterns.

// lib/spam-validators.ts
export function containsSpamPatterns(text: string): string | true {
  const lowerText = text.toLowerCase();

  // Common spam keywords
  const spamKeywords = [
    'viagra', 'casino', 'lottery', 'bitcoin investment',
    'make money fast', 'work from home opportunity',
    'click here now', 'limited time offer',
  ];

  for (const keyword of spamKeywords) {
    if (lowerText.includes(keyword)) {
      return 'Message contains prohibited content';
    }
  }

  // Too many URLs
  const urlCount = (text.match(/https?:\/\//gi) || []).length;
  if (urlCount > 3) {
    return 'Too many links in message';
  }

  // All caps screaming
  const capsRatio = (text.match(/[A-Z]/g) || []).length / text.length;
  if (text.length > 50 && capsRatio > 0.7) {
    return 'Please avoid excessive capitalization';
  }

  return true;
}

export function isDisposableEmail(email: string): string | true {
  const disposableDomains = new Set([
    'tempmail.com', 'guerrillamail.com', 'mailinator.com',
    '10minutemail.com', 'throwaway.email', 'temp-mail.org',
    'fakeinbox.com', 'trashmail.com', 'sharklasers.com',
  ]);

  const domain = email.split('@')[1]?.toLowerCase();

  if (domain && disposableDomains.has(domain)) {
    return 'Please use a permanent email address';
  }

  return true;
}

Now use these in your form:

// components/validated-form.tsx
'use client';

import { useForm } from 'react-hook-form';
import { containsSpamPatterns, isDisposableEmail } from '@/lib/spam-validators';

interface FormData {
  email: string;
  message: string;
}

export function ValidatedForm() {
  const { register, handleSubmit, formState: { errors } } = useForm<FormData>();

  return (
    <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
      <div>
        <input
          {...register('email', {
            required: 'Email is required',
            validate: isDisposableEmail,
          })}
          type="email"
          placeholder="Email"
          className="w-full border rounded px-3 py-2"
        />
        {errors.email && (
          <p className="text-red-500 text-sm">{errors.email.message}</p>
        )}
      </div>

      <div>
        <textarea
          {...register('message', {
            required: 'Message is required',
            validate: containsSpamPatterns,
          })}
          placeholder="Your message"
          rows={5}
          className="w-full border rounded px-3 py-2"
        />
        {errors.message && (
          <p className="text-red-500 text-sm">{errors.message.message}</p>
        )}
      </div>

      <button type="submit" className="bg-blue-600 text-white px-4 py-2 rounded">
        Submit
      </button>
    </form>
  );
}

Custom validators give you immediate feedback to users while blocking obvious spam. But be careful—make error messages generic enough that spammers can’t learn your exact rules.

Technique 4: Integrating External Spam APIs

Client-side checks are easily bypassed. The real protection happens server-side with specialized APIs that analyze IP reputation, email validity, and content patterns.

Here’s how to integrate FormShield with React Hook Form:

// components/protected-form.tsx
'use client';

import { useForm } from 'react-hook-form';
import { useRef, useState } from 'react';

interface FormData {
  name: string;
  email: string;
  message: string;
  company?: string; // honeypot
}

interface FormMetadata {
  formLoadedAt: number;
  submittedAt: number;
}

export function ProtectedContactForm() {
  const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm<FormData>();
  const formLoadedAt = useRef<number>(Date.now());
  const [result, setResult] = useState<{ success: boolean; message: string } | null>(null);

  async function onSubmit(data: FormData) {
    // Client-side honeypot check
    if (data.company) {
      setResult({ success: true, message: "Thanks! We'll be in touch." });
      return;
    }

    const metadata: FormMetadata = {
      formLoadedAt: formLoadedAt.current,
      submittedAt: Date.now(),
    };

    try {
      const response = await fetch('/api/contact', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          name: data.name,
          email: data.email,
          message: data.message,
          metadata,
        }),
      });

      const json = await response.json();
      setResult({
        success: response.ok,
        message: response.ok ? "Thanks! We'll be in touch." : 'Something went wrong.',
      });
    } catch {
      setResult({ success: false, message: 'Connection error. Please try again.' });
    }
  }

  if (result?.success) {
    return <p className="text-green-600">{result.message}</p>;
  }

  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: 'Name is required' })}
          className="w-full border rounded px-3 py-2"
        />
        {errors.name && <p className="text-red-500 text-sm">{errors.name.message}</p>}
      </div>

      <div>
        <label htmlFor="email" className="block text-sm font-medium">Email</label>
        <input
          id="email"
          type="email"
          {...register('email', { required: 'Email is required' })}
          className="w-full border rounded px-3 py-2"
        />
        {errors.email && <p className="text-red-500 text-sm">{errors.email.message}</p>}
      </div>

      <div>
        <label htmlFor="message" className="block text-sm font-medium">Message</label>
        <textarea
          id="message"
          rows={5}
          {...register('message', { required: 'Message is required' })}
          className="w-full border rounded px-3 py-2"
        />
        {errors.message && <p className="text-red-500 text-sm">{errors.message.message}</p>}
      </div>

      {/* Honeypot */}
      <div aria-hidden="true" style={{ position: 'absolute', left: '-9999px' }}>
        <input {...register('company')} 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>

      {result && !result.success && (
        <p className="text-red-500">{result.message}</p>
      )}
    </form>
  );
}

The API route handles the FormShield integration:

// app/api/contact/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { FormShield } from '@formshield/next';

const formshield = new FormShield({
  apiKey: process.env.FORMSHIELD_API_KEY!,
});

interface ContactPayload {
  name: string;
  email: string;
  message: string;
  metadata: {
    formLoadedAt: number;
    submittedAt: number;
  };
}

export async function POST(request: NextRequest) {
  try {
    const data: ContactPayload = await request.json();

    // Get client IP from headers
    const ip = request.headers.get('x-forwarded-for')?.split(',')[0]
      || request.headers.get('x-real-ip')
      || 'unknown';

    // Check with FormShield
    const result = await formshield.check({
      email: data.email,
      name: data.name,
      content: data.message,
      ip,
      formId: 'contact-form',
      metadata: {
        formLoadedAt: data.metadata.formLoadedAt,
        honeypotField: '', // Already checked client-side
      },
    });

    // Log the result for debugging
    console.log('FormShield result:', {
      verdict: result.verdict,
      score: result.score,
      action: result.action,
    });

    // Handle based on action
    if (result.action === 'block') {
      // Return fake success - don't reveal to spammers
      return NextResponse.json({ success: true });
    }

    if (result.action === 'review') {
      // Queue for manual review
      await queueForReview(data, result);
      return NextResponse.json({ success: true });
    }

    // result.action === 'allow' - process normally
    await sendEmail({
      to: 'hello@example.com',
      subject: `Contact from ${data.name}`,
      body: data.message,
      replyTo: data.email,
    });

    return NextResponse.json({ success: true });
  } catch (error) {
    console.error('Contact form error:', error);
    return NextResponse.json(
      { error: 'Internal server error' },
      { status: 500 }
    );
  }
}

async function queueForReview(data: ContactPayload, result: FormShieldResult) {
  // Save to database with spam score for manual review
  // Implementation depends on your database
}

async function sendEmail(params: { to: string; subject: string; body: string; replyTo: string }) {
  // Send via your email provider
}

FormShield returns a detailed breakdown:

{
  verdict: 'spam' | 'ham' | 'review',
  score: 7.2,  // 0-10 scale
  confidence: 0.89,
  action: 'block' | 'allow' | 'review',
  signals: {
    ip: {
      isVpn: true,
      isDatacenter: false,
      threatScore: 0.7,
      country: 'RU',
    },
    email: {
      isDisposable: true,
      domainAge: 2,  // days
      hasMxRecords: true,
    },
    content: {
      spamProbability: 0.82,
      language: 'en',
      hasLinks: true,
    },
    behavioral: {
      timeOnForm: 1200,  // ms
      suspicious: true,
    },
  },
  ruleMatches: ['disposable_email', 'vpn_ip', 'fast_submission'],
}

This gives you way more signal than any client-side check could provide.

Putting It All Together: The Complete Form

Here’s a production-ready form combining all techniques:

// components/complete-protected-form.tsx
'use client';

import { useForm } from 'react-hook-form';
import { useRef, useState, useCallback } from 'react';
import { containsSpamPatterns, isDisposableEmail } from '@/lib/spam-validators';

interface FormData {
  name: string;
  email: string;
  message: string;
  website?: string; // honeypot
}

type FormStatus = 'idle' | 'submitting' | 'success' | 'error';

export function CompleteProtectedForm() {
  const {
    register,
    handleSubmit,
    formState: { errors },
    reset,
  } = useForm<FormData>();

  const formLoadedAt = useRef<number>(Date.now());
  const [status, setStatus] = useState<FormStatus>('idle');
  const [errorMessage, setErrorMessage] = useState<string>('');

  const onSubmit = useCallback(async (data: FormData) => {
    setStatus('submitting');
    setErrorMessage('');

    // Honeypot check
    if (data.website) {
      // Fake success for bots
      setStatus('success');
      return;
    }

    // Timing check
    const timeOnForm = Date.now() - formLoadedAt.current;
    if (timeOnForm < 2000) {
      // Too fast - fake success
      setStatus('success');
      return;
    }

    try {
      const response = await fetch('/api/contact', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          name: data.name,
          email: data.email,
          message: data.message,
          metadata: {
            formLoadedAt: formLoadedAt.current,
            submittedAt: Date.now(),
            timeOnForm,
          },
        }),
      });

      if (response.ok) {
        setStatus('success');
        reset();
      } else {
        setStatus('error');
        setErrorMessage('Failed to send message. Please try again.');
      }
    } catch {
      setStatus('error');
      setErrorMessage('Connection error. Please check your internet.');
    }
  }, [reset]);

  if (status === 'success') {
    return (
      <div className="bg-green-50 border border-green-200 rounded-lg p-6 text-center">
        <h3 className="text-green-800 font-semibold text-lg">Message Sent!</h3>
        <p className="text-green-600 mt-2">We'll get back to you within 24 hours.</p>
        <button
          onClick={() => {
            setStatus('idle');
            formLoadedAt.current = Date.now();
          }}
          className="mt-4 text-green-700 underline hover:no-underline"
        >
          Send another message
        </button>
      </div>
    );
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
      <div>
        <label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-1">
          Your Name
        </label>
        <input
          id="name"
          {...register('name', {
            required: 'Please enter your name',
            minLength: { value: 2, message: 'Name is too short' },
            maxLength: { value: 100, message: 'Name is too long' },
          })}
          className="w-full border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
          placeholder="Jane Smith"
        />
        {errors.name && (
          <p className="text-red-500 text-sm mt-1">{errors.name.message}</p>
        )}
      </div>

      <div>
        <label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">
          Email Address
        </label>
        <input
          id="email"
          type="email"
          {...register('email', {
            required: 'Please enter your email',
            pattern: {
              value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
              message: 'Please enter a valid email',
            },
            validate: isDisposableEmail,
          })}
          className="w-full border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent"
          placeholder="jane@company.com"
        />
        {errors.email && (
          <p className="text-red-500 text-sm mt-1">{errors.email.message}</p>
        )}
      </div>

      <div>
        <label htmlFor="message" className="block text-sm font-medium text-gray-700 mb-1">
          Message
        </label>
        <textarea
          id="message"
          rows={6}
          {...register('message', {
            required: 'Please enter a message',
            minLength: { value: 10, message: 'Message is too short' },
            maxLength: { value: 5000, message: 'Message is too long' },
            validate: containsSpamPatterns,
          })}
          className="w-full border border-gray-300 rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500 focus:border-transparent resize-y"
          placeholder="How can we help you?"
        />
        {errors.message && (
          <p className="text-red-500 text-sm mt-1">{errors.message.message}</p>
        )}
      </div>

      {/* Honeypot - visually hidden but accessible to bots */}
      <div
        aria-hidden="true"
        style={{
          position: 'absolute',
          left: '-9999px',
          width: '1px',
          height: '1px',
          overflow: 'hidden',
        }}
      >
        <label htmlFor="website">Website (leave blank)</label>
        <input
          id="website"
          {...register('website')}
          tabIndex={-1}
          autoComplete="off"
        />
      </div>

      {status === 'error' && errorMessage && (
        <div className="bg-red-50 border border-red-200 rounded-lg p-4">
          <p className="text-red-600">{errorMessage}</p>
        </div>
      )}

      <button
        type="submit"
        disabled={status === 'submitting'}
        className="w-full bg-blue-600 text-white font-medium px-6 py-3 rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
      >
        {status === 'submitting' ? (
          <span className="flex items-center justify-center gap-2">
            <svg className="animate-spin h-5 w-5" viewBox="0 0 24 24">
              <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
              <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
            </svg>
            Sending...
          </span>
        ) : (
          'Send Message'
        )}
      </button>
    </form>
  );
}

This form has four layers of protection:

  1. Honeypot field catches basic bots
  2. Timing analysis filters automated submissions
  3. Custom validation blocks obvious spam patterns
  4. Server-side API (FormShield) provides the heavy lifting

Performance Considerations

A few things to keep in mind for production:

Don’t block rendering for spam checks. Run API calls after the user submits, not on every keystroke.

Cache disposable email lists. If you’re checking email domains client-side, load the list once and cache it.

Use optimistic UI. Show the success state immediately while the API processes in the background. If it turns out to be spam, no one needs to know.

Rate limit your endpoint. Even with spam detection, add basic rate limiting (e.g., 10 requests per minute per IP) to prevent abuse.

When to Use Each Technique

Here’s a decision framework:

TechniqueCatchesUser ImpactEffort
HoneypotBasic botsNoneLow
TimingAutomated scriptsNoneLow
Custom validationObvious spamMinimalMedium
External APISophisticated spamNoneLow (with SDK)

Start with honeypots and timing—they’re free and catch a lot. Add custom validation for your specific patterns. For production forms, integrate an API like FormShield that handles the edge cases.

Wrapping Up

Spam protection for React Hook Form doesn’t have to mean frustrating your users with CAPTCHAs. By layering honeypots, timing analysis, custom validation, and API-based detection, you can catch the vast majority of spam while keeping your forms frictionless.

The code examples in this guide are production-ready. Copy them, adapt them, and never deal with spam manually again.


FormShield provides unified spam detection for web forms—one API endpoint that combines IP intelligence, email validation, AI content analysis, and behavioral signals. Get started free with 1,000 requests per month.

Stop fighting spam by hand

One API call. IP, email, content & behavior signals in a single intelligence platform. Start free, no credit card required.