
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:
- Select Score based (v3)
- Add your domains — include
localhostfor development - 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
⚠️ 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
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 });
}
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>
);
}
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 };
}
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>
);
}
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>
);
}
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 });
}
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>
);
}
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:
- User fills out the form — all input types captured in local state
- User clicks Submit —
handleSubmitfires verify()is called — triggersexecuteRecaptcha()from Google's script- Google generates a token — based on user behavior analysis
- Token sent to your API —
/api/verify-recaptchareceives it - Server verifies with Google — your secret key + token = score
- 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
RecaptchaProviderin 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 intry/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
RecaptchaProvidercomponent - ✅ Clean custom
useRecaptchahook - ✅ 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. 🤝
Muhammad Hamid Raza
Content Author
Originally published on Dev.to • Content syndicated with permission