DEV Community

Cover image for How to Integrate Google reCAPTCHA v3 in Next.js 15+ (Step-by-Step Guide)
Muhammad Hamid Raza
Muhammad Hamid Raza

Posted on • Edited on

How to Integrate Google reCAPTCHA v3 in Next.js 15+ (Step-by-Step Guide)

You just launched a contact form on your Next.js app. Within 24 hours, your inbox is flooded with 300 fake submissions and your database looks like a spam convention. 😩

Sound familiar?

That's what happens when your forms have zero protection. And honestly, it's one of those things developers always say "I'll add later" — until later becomes a disaster.

That's where Google reCAPTCHA v3 comes in. Unlike the old checkbox-style CAPTCHA (you know, the one where you pick traffic lights for 45 seconds), reCAPTCHA v3 works invisibly in the background. No annoying puzzles. No user friction. Just a smart bot-detection score that quietly protects every form on your site.

In this guide, you'll learn how to integrate reCAPTCHA v3 into a Next.js 15+ app — step by step, with actual working code. We're not skipping the hard parts.


What Is Google reCAPTCHA v3?

Think of reCAPTCHA v3 as a silent security bouncer standing at the door of your forms.

Every time a user submits a form, Google runs a behavioral analysis in the background and returns a score from 0.0 to 1.0:

  • 1.0 → Definitely human āœ…
  • 0.0 → Almost certainly a bot šŸ¤–

Your server then decides what to do based on that score. If the score is above your threshold (usually 0.5), you let the request through. If not, you block it.

It's like your app quietly asking Google: "Hey, is this person legit?" — and Google replies in milliseconds.

No user interaction required. No annoying image grids. Clean and invisible.


Why This Matters for Your Next.js App

Here's the real talk: any public-facing form is a target. Contact forms, login forms, signup forms, newsletter subscriptions — bots hit them all.

The consequences? Spam in your database, fake accounts, DDoS-style form abuse, and higher server bills from wasted API calls. None of that is fun to deal with on a Monday morning.

reCAPTCHA v3 solves this at the source. And when combined with Next.js API routes, you get a clean, server-verified protection layer that bots can't fake — because the token is verified directly with Google's servers on your backend.

Have you ever dealt with spam bots destroying your form submissions? If yes, this guide is exactly what you need. šŸ‘‡


Benefits of Using reCAPTCHA v3 in Next.js

  • Zero user friction — no checkboxes, no puzzles, no "click all the buses" nonsense
  • Works on every form type — text inputs, dropdowns, checkboxes, radio buttons, sliders — all protected
  • Server-side verification — the token is validated on your backend, not just on the client
  • Score-based control — you decide the threshold; adjust it based on your app's sensitivity
  • Free to use — Google's reCAPTCHA v3 API is free for most use cases
  • Next.js 15 compatible — works great with the App Router and Server Actions pattern

reCAPTCHA v3 vs reCAPTCHA v2 — What's the Difference?

Feature reCAPTCHA v2 reCAPTCHA v3
User Interaction Checkbox / Image puzzle None (invisible)
Bot Detection Binary (pass/fail) Score-based (0.0–1.0)
User Experience Annoying, creates friction Seamless, zero friction
Customization Limited Control threshold per action
Best For Simple apps Production apps with multiple forms

Bottom line: v2 is old school. v3 is what you want if you care about user experience and security at the same time.


Full Implementation — Step by Step

Let's build this out properly. Here's the complete integration for a Next.js 15+ app.


Step 1: Get Your reCAPTCHA Keys

Head over to the Google reCAPTCHA Admin Console:

  1. Select Score based (v3)
  2. Add your domains — include localhost for development
  3. Copy your Site Key and Secret Key

Step 2: Set Up Environment Variables

Create or update your .env.local file:

NEXT_PUBLIC_RECAPTCHA_SITE_KEY=your-site-key-here
RECAPTCHA_SECRET_KEY=your-secret-key-here
Enter fullscreen mode Exit fullscreen mode

āš ļø The NEXT_PUBLIC_ prefix makes the site key available on the client side. Never expose your secret key to the frontend.


Step 3: Install the Package

npm install react-google-recaptcha-v3
Enter fullscreen mode Exit fullscreen mode

One package. That's it. Nothing heavy.


Step 4: Create the Verification API Route

This is the most important piece — your server verifies the reCAPTCHA token directly with Google.

app/api/verify-recaptcha/route.ts

export async function POST(request: Request) {
  const { token } = await request.json();
  const secretKey = process.env.RECAPTCHA_SECRET_KEY;

  const response = await fetch('https://www.google.com/recaptcha/api/siteverify', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: `secret=${secretKey}&response=${token}`,
  });

  const data = await response.json();

  if (data.success && data.score > 0.5) {
    return Response.json({ success: true, score: data.score });
  }

  return Response.json({ error: 'Verification failed' }, { status: 400 });
}
Enter fullscreen mode Exit fullscreen mode

This route:

  • Receives the token from your frontend
  • Sends it to Google's verification endpoint along with your secret key
  • Returns success only if Google gives a score above 0.5

Step 5: Create the reCAPTCHA Provider Component

components/RecaptchaProvider.tsx

'use client';

import { GoogleReCaptchaProvider } from 'react-google-recaptcha-v3';

export function RecaptchaProvider({ children }: { children: React.ReactNode }) {
  const siteKey = process.env.NEXT_PUBLIC_RECAPTCHA_SITE_KEY;

  return (
    <GoogleReCaptchaProvider reCaptchaKey={siteKey!}>
      {children}
    </GoogleReCaptchaProvider>
  );
}
Enter fullscreen mode Exit fullscreen mode

This wraps your app (or a specific page) with the reCAPTCHA context so every child component can access the executeRecaptcha function.


Step 6: Create a Reusable Custom Hook

This is where you keep all the reCAPTCHA logic clean and reusable across multiple forms.

hooks/useRecaptcha.ts

'use client';

import { useCallback } from 'react';
import { useGoogleReCaptcha } from 'react-google-recaptcha-v3';

export function useRecaptcha() {
  const { executeRecaptcha } = useGoogleReCaptcha();

  const verify = useCallback(async (action: string = 'form_submit') => {
    if (!executeRecaptcha) {
      throw new Error('reCAPTCHA not ready');
    }

    const token = await executeRecaptcha(action);

    const response = await fetch('/api/verify-recaptcha', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ token }),
    });

    const data = await response.json();

    if (!response.ok) {
      throw new Error(data.error);
    }

    return data;
  }, [executeRecaptcha]);

  return { verify };
}
Enter fullscreen mode Exit fullscreen mode

Now any form can just call verify('action_name') — clean, simple, reusable.


Step 7: Build a Basic Protected Form

Here's a simple contact form using the hook we just created.

components/ProtectedForm.tsx

'use client';

import { useState } from 'react';
import { useRecaptcha } from '@/hooks/useRecaptcha';

export function ProtectedForm() {
  const { verify } = useRecaptcha();
  const [isSubmitting, setIsSubmitting] = useState(false);
  const [formData, setFormData] = useState({
    name: '',
    email: '',
    message: ''
  });

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setIsSubmitting(true);

    try {
      // reCAPTCHA verification happens here
      await verify('contact_form');

      const response = await fetch('/api/submit-form', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(formData),
      });

      if (response.ok) {
        alert('Form submitted successfully!');
        setFormData({ name: '', email: '', message: '' });
      }
    } catch (error) {
      alert('Verification failed. Please try again.');
    } finally {
      setIsSubmitting(false);
    }
  };

  return (
    <form onSubmit={handleSubmit} className="space-y-4">
      <div>
        <label className="block mb-1">Name</label>
        <input
          type="text"
          value={formData.name}
          onChange={(e) => setFormData({ ...formData, name: e.target.value })}
          className="w-full p-2 border rounded"
          required
        />
      </div>

      <div>
        <label className="block mb-1">Email</label>
        <input
          type="email"
          value={formData.email}
          onChange={(e) => setFormData({ ...formData, email: e.target.value })}
          className="w-full p-2 border rounded"
          required
        />
      </div>

      <div>
        <label className="block mb-1">Message</label>
        <textarea
          value={formData.message}
          onChange={(e) => setFormData({ ...formData, message: e.target.value })}
          className="w-full p-2 border rounded"
          rows={4}
          required
        />
      </div>

      <button
        type="submit"
        disabled={isSubmitting}
        className="bg-blue-500 text-white px-4 py-2 rounded disabled:bg-gray-400"
      >
        {isSubmitting ? 'Verifying...' : 'Submit'}
      </button>
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

Step 8: Full Registration Form With All Input Types

This is the real deal — a complete form covering every input type, all protected by reCAPTCHA v3.

components/CompleteForm.tsx

'use client';

import { useState } from 'react';
import { useRecaptcha } from '@/hooks/useRecaptcha';

export function CompleteForm() {
  const { verify } = useRecaptcha();
  const [isSubmitting, setIsSubmitting] = useState(false);
  const [formData, setFormData] = useState({
    fullName: '',
    email: '',
    phone: '',
    age: '',
    country: '',
    gender: '',
    password: '',
    confirmPassword: '',
    newsletter: false,
    terms: false,
    bio: '',
    experience: 'beginner',
    rating: '5'
  });

  const handleChange = (
    e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>
  ) => {
    const { name, value, type } = e.target;
    const checked = (e.target as HTMLInputElement).checked;

    setFormData(prev => ({
      ...prev,
      [name]: type === 'checkbox' ? checked : value
    }));
  };

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setIsSubmitting(true);

    try {
      await verify('complete_form');

      const response = await fetch('/api/submit-form', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(formData),
      });

      if (response.ok) {
        alert('Form submitted successfully!');
        setFormData({
          fullName: '', email: '', phone: '', age: '', country: '', gender: '',
          password: '', confirmPassword: '', newsletter: false, terms: false,
          bio: '', experience: 'beginner', rating: '5'
        });
      }
    } catch (error) {
      alert('Security verification failed. Please try again.');
    } finally {
      setIsSubmitting(false);
    }
  };

  return (
    <form onSubmit={handleSubmit} className="max-w-2xl mx-auto p-6 space-y-4">
      <h2 className="text-2xl font-bold mb-6">Registration Form</h2>

      {/* Text Input */}
      <div>
        <label className="block mb-1">Full Name *</label>
        <input type="text" name="fullName" value={formData.fullName}
          onChange={handleChange} required className="w-full p-2 border rounded" />
      </div>

      {/* Email Input */}
      <div>
        <label className="block mb-1">Email *</label>
        <input type="email" name="email" value={formData.email}
          onChange={handleChange} required className="w-full p-2 border rounded" />
      </div>

      {/* Phone Input */}
      <div>
        <label className="block mb-1">Phone</label>
        <input type="tel" name="phone" value={formData.phone}
          onChange={handleChange} className="w-full p-2 border rounded" />
      </div>

      {/* Number Input */}
      <div>
        <label className="block mb-1">Age</label>
        <input type="number" name="age" value={formData.age}
          onChange={handleChange} min="18" max="100" className="w-full p-2 border rounded" />
      </div>

      {/* Select Dropdown - Country */}
      <div>
        <label className="block mb-1">Country *</label>
        <select name="country" value={formData.country}
          onChange={handleChange} required className="w-full p-2 border rounded">
          <option value="">Select country</option>
          <option value="usa">United States</option>
          <option value="uk">United Kingdom</option>
          <option value="canada">Canada</option>
          <option value="india">India</option>
        </select>
      </div>

      {/* Select Dropdown - Gender */}
      <div>
        <label className="block mb-1">Gender</label>
        <select name="gender" value={formData.gender}
          onChange={handleChange} className="w-full p-2 border rounded">
          <option value="">Select gender</option>
          <option value="male">Male</option>
          <option value="female">Female</option>
          <option value="other">Other</option>
        </select>
      </div>

      {/* Password Inputs */}
      <div>
        <label className="block mb-1">Password *</label>
        <input type="password" name="password" value={formData.password}
          onChange={handleChange} required className="w-full p-2 border rounded" />
      </div>

      <div>
        <label className="block mb-1">Confirm Password *</label>
        <input type="password" name="confirmPassword" value={formData.confirmPassword}
          onChange={handleChange} required className="w-full p-2 border rounded" />
      </div>

      {/* Radio Buttons - Experience Level */}
      <div>
        <label className="block mb-1">Experience Level</label>
        <div className="space-x-4">
          {['beginner', 'intermediate', 'advanced'].map(level => (
            <label key={level}>
              <input type="radio" name="experience" value={level}
                checked={formData.experience === level}
                onChange={handleChange} className="mr-1" />
              {level.charAt(0).toUpperCase() + level.slice(1)}
            </label>
          ))}
        </div>
      </div>

      {/* Range Slider */}
      <div>
        <label className="block mb-1">Rating: {formData.rating}</label>
        <input type="range" name="rating" value={formData.rating}
          onChange={handleChange} min="1" max="10" className="w-full" />
      </div>

      {/* Textarea */}
      <div>
        <label className="block mb-1">Bio</label>
        <textarea name="bio" value={formData.bio} onChange={handleChange}
          rows={4} className="w-full p-2 border rounded"
          placeholder="Tell us about yourself..." />
      </div>

      {/* Checkboxes */}
      <div className="space-y-2">
        <label className="flex items-center">
          <input type="checkbox" name="newsletter" checked={formData.newsletter}
            onChange={handleChange} className="mr-2" />
          Subscribe to newsletter
        </label>
        <label className="flex items-center">
          <input type="checkbox" name="terms" checked={formData.terms}
            onChange={handleChange} required className="mr-2" />
          I accept the terms and conditions *
        </label>
      </div>

      {/* Submit Button */}
      <button type="submit" disabled={isSubmitting}
        className="w-full bg-blue-500 text-white p-2 rounded hover:bg-blue-600 disabled:bg-gray-400">
        {isSubmitting ? 'Verifying...' : 'Submit Form'}
      </button>

      <p className="text-xs text-gray-500 text-center">
        Protected by reCAPTCHA v3 — No user interaction required
      </p>
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

Step 9: Form Submission API Route

app/api/submit-form/route.ts

export async function POST(request: Request) {
  const formData = await request.json();

  // Process your form data here
  // Save to database, send email, etc.
  console.log('Form submitted:', formData);

  return Response.json({ success: true });
}
Enter fullscreen mode Exit fullscreen mode

Step 10: Wire It All Together in Your Page

app/page.tsx

import { RecaptchaProvider } from '@/components/RecaptchaProvider';
import { CompleteForm } from '@/components/CompleteForm';

export default function Home() {
  return (
    <div className="min-h-screen bg-gray-50 py-12">
      <RecaptchaProvider>
        <CompleteForm />
      </RecaptchaProvider>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

That's it. Wrap your page in RecaptchaProvider and drop in your form. The reCAPTCHA badge will appear in the bottom-right corner — that's how you know it's running. āœ…


How the Full Flow Works

Here's what happens end-to-end when a user submits the form:

  1. User fills out the form — all input types captured in local state
  2. User clicks Submit — handleSubmit fires
  3. verify() is called — triggers executeRecaptcha() from Google's script
  4. Google generates a token — based on user behavior analysis
  5. Token sent to your API — /api/verify-recaptcha receives it
  6. Server verifies with Google — your secret key + token = score
  7. Score checked — above 0.5? Form submits. Below? Blocked.

It's as smooth as unlocking your phone once you know the password — the user barely notices it's happening.


Best Tips: Do's and Don'ts

āœ… Do's

  • Do use different action names per form — 'login', 'contact_form', 'signup'. This helps Google learn patterns for each action.
  • Do verify on the server — never trust a client-side token without backend verification.
  • Do use environment variables — never hardcode keys in your code.
  • Do wrap only the pages that need it — don't put RecaptchaProvider in your root layout unless every page needs reCAPTCHA.
  • Do handle errors gracefully — show a user-friendly message if verification fails.

āŒ Don'ts

  • Don't expose your secret key on the client side — it belongs only in server-side environment variables.
  • Don't set the threshold too high (like 0.9) for all users — legitimate users sometimes get lower scores depending on browser/network.
  • Don't skip error handling in handleSubmit — always wrap in try/catch.
  • Don't test in incognito mode without registering localhost — it can cause reCAPTCHA loading issues.

Common Mistakes Developers Make

1. Not wrapping the form in RecaptchaProvider
If your form uses useRecaptcha but isn't inside RecaptchaProvider, you'll get a "reCAPTCHA not ready" error. Always check your component tree.

2. Verifying only on the client
Some developers check the token on the frontend and skip the API verification. That's useless — bots can fake client-side checks. Always verify server-side.

3. Using the same action name for everything
If you label every form action as 'form_submit', Google can't distinguish between your login page and your contact form. Use specific names.

4. Forgetting to add localhost to the allowed domains
You'll get a sitekey not valid for domain error during development. Add localhost in the reCAPTCHA admin console.

5. Blocking all users below 0.5
Some real users on slow networks or unusual browsers score lower. Consider logging scores and testing before going too strict. You can always start at 0.3 and tighten later.


Conclusion — Go Protect Those Forms šŸ”

Bots aren't going to stop anytime soon. But with reCAPTCHA v3 wired into your Next.js 15+ app, you've got a solid, invisible, user-friendly defense layer running on every form submission.

Here's a quick recap of what you built:

  • āœ… Environment variables set up correctly
  • āœ… Verification API route on the server
  • āœ… Reusable RecaptchaProvider component
  • āœ… Clean custom useRecaptcha hook
  • āœ… Protected forms covering all input types
  • āœ… Score-based bot detection logic

Now go update your existing forms and stop letting bots ruin your day.

And if you found this guide useful, there's a lot more where that came from. Head over to hamidrazadev.com for more developer guides, Next.js tips, and practical tutorials written the same way — no fluff, just working code. šŸš€

Feel free to share this post with a developer friend who's still dealing with bot spam in 2025. They'll thank you for it. šŸ¤

Top comments (0)