How to Add reCAPTCHA v3 to Your Next.js 15.5 Contact Form Without Breaking Anything
Implement invisible bot protection while preserving existing form functionality

⚡ Next.js Implementation Guides
In-depth Next.js guides covering App Router, RSC, ISR, and deployment. Get code examples, optimization checklists, and prompts to accelerate development.
I was building a client project with a sophisticated contact form when bot submissions started flooding in. The form already had React Hook Form validation, internationalization, and custom error handling that I couldn't afford to break. After implementing reCAPTCHA v3 successfully while preserving every existing feature, I'm sharing the complete step-by-step process.
This guide shows you exactly how to add invisible bot protection to your Next.js forms without disrupting your existing functionality. By the end, you'll have enterprise-level security that your users won't even notice.
The Challenge with Existing Forms
Most reCAPTCHA tutorials assume you're starting from scratch, but real projects have complex forms with existing patterns. My contact form used React Hook Form with Zod validation, Server Actions for submission, next-intl for internationalization, and Sonner for toast notifications. Breaking any of this would mean rewriting working code.
The key insight is that reCAPTCHA v3 works invisibly in the background, so we can layer it on top of existing functionality rather than rebuilding everything.
Setting Up Environment Variables
First, you'll need your reCAPTCHA keys from the Google reCAPTCHA console. Add them to your environment file, keeping the client-side key prefixed with NEXT_PUBLIC_
:
# File: .env
NEXT_PUBLIC_RECAPTCHA_SITE_KEY=your_site_key_here
RECAPTCHA_SECRET_KEY=your_secret_key_here
The site key is safe to expose to the browser since it's designed for client-side use, while the secret key stays server-side only. This separation allows Google's verification system to confirm that requests actually came from your domain.
Creating the Verification Utility
Before modifying your existing form, create a robust server-side verification function. This handles the communication with Google's API and implements the scoring logic:
// File: src/lib/recaptcha.ts
interface RecaptchaVerificationResult {
success: boolean;
score?: number;
action?: string;
challenge_ts?: string;
hostname?: string;
"error-codes"?: string[];
}
export async function verifyRecaptcha(token: string): Promise<{
success: boolean;
score?: number;
error?: string;
}> {
try {
const response = await fetch(
"https://www.google.com/recaptcha/api/siteverify",
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: `secret=${process.env.RECAPTCHA_SECRET_KEY}&response=${token}`,
}
);
if (!response.ok) {
return {
success: false,
error: "Failed to verify reCAPTCHA",
};
}
const result: RecaptchaVerificationResult = await response.json();
if (!result.success) {
return {
success: false,
error:
result["error-codes"]?.join(", ") || "reCAPTCHA verification failed",
};
}
// For reCAPTCHA v3, check the score (0.0 to 1.0)
// 0.5 is the recommended threshold
if (result.score !== undefined && result.score < 0.5) {
return {
success: false,
score: result.score,
error: "Security verification failed. Please try again.",
};
}
// Verify the action name matches what we expect
if (result.action && result.action !== "contact_form") {
return {
success: false,
error: "Invalid action name",
};
}
return {
success: true,
score: result.score,
};
} catch (error) {
console.error("reCAPTCHA verification error:", error);
return {
success: false,
error: "Network error during security verification",
};
}
}
This utility function encapsulates all the complexity of Google's verification API. The score-based approach of reCAPTCHA v3 means we get a probability rating rather than a simple pass/fail, allowing for more nuanced security decisions. The 0.5 threshold is Google's recommended starting point, but you can adjust it based on your traffic patterns.
Updating Your Server Action
Now modify your existing server action to include reCAPTCHA verification as the first step. This ensures no processing happens without valid verification:
// File: src/actions/form.ts
"use server";
import { z } from "zod";
import { sendContactEmailWithBrevo } from "@/lib/brevo";
import {
createContactEmailContent,
createReplyToVisitorEmailContent,
} from "@/utils/prepare-email";
import { verifyRecaptcha } from "@/lib/recaptcha";
const formSchema = z.object({
name: z.string().min(2, {
message: "Name must be at least 2 characters.",
}),
email: z.string().email({
message: "Please enter a valid email address.",
}),
message: z.string().min(10, {
message: "Message must be at least 10 characters.",
}),
});
type FormData = z.infer<typeof formSchema>;
export async function submitContactForm(
formData: FormData,
recaptchaToken: string
) {
"use server";
try {
// 1. Verify reCAPTCHA first
const recaptchaResult = await verifyRecaptcha(recaptchaToken);
if (!recaptchaResult.success) {
return {
success: false,
message:
recaptchaResult.error ||
"Security verification failed. Please try again.",
};
}
// 2. Validate the form data
const validatedData = formSchema.parse(formData);
const { name, email, message } = validatedData;
// 3. Continue with your existing email logic
const adminEmail = await createContactEmailContent(name, email, message);
await sendContactEmailWithBrevo({
from: name,
textContent: adminEmail.textContent,
htmlContent: adminEmail.htmlContent,
to: "your-email@domain.com",
subject: `New contact form submission from ${name}`,
});
const visitorEmail = await createReplyToVisitorEmailContent(name);
await sendContactEmailWithBrevo({
from: "Your Site",
textContent: visitorEmail.textContent,
htmlContent: visitorEmail.htmlContent,
to: email,
subject: "Thank you for contacting us",
});
return {
success: true,
message: "Your message has been sent successfully!",
};
} catch (error) {
console.error("Error submitting contact form:", error);
if (error instanceof z.ZodError) {
return {
success: false,
message: "Validation failed",
errors: error.errors.map((e) => ({
path: e.path.join("."),
message: e.message,
})),
};
}
return {
success: false,
message: "Failed to send your message. Please try again later.",
};
}
}
The critical change here is adding the reCAPTCHA token parameter and verifying it before any other processing. This creates a security gate that stops bots before they can even trigger your validation logic or email sending. All your existing error handling and response patterns remain exactly the same.
Integrating with Your Client Component
The client-side integration requires the most careful handling since it needs to work seamlessly with your existing form logic. Here's how to enhance your contact form component:
// File: src/components/contact-form.tsx
"use client";
import { z } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { useTransition, useState } from "react";
import { submitContactForm } from "@/actions/form";
import { useTranslations } from "next-intl";
import Script from "next/script";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { toast } from "sonner";
declare global {
interface Window {
grecaptcha: {
ready: (callback: () => void) => void;
execute: (
siteKey: string,
options: { action: string }
) => Promise<string>;
};
}
}
export function ContactForm() {
const t = useTranslations("HomePage");
const [isPending, startTransition] = useTransition();
const [isRecaptchaLoading, setIsRecaptchaLoading] = useState(false);
const [recaptchaReady, setRecaptchaReady] = useState(false);
// Your existing form schema and setup
const formSchema = z.object({
name: z.string().min(2, {
message:
t("contact.form.validation.nameRequired") ||
"Name must be at least 2 characters.",
}),
email: z.string().email({
message:
t("contact.form.validation.emailInvalid") ||
"Please enter a valid email address.",
}),
message: z.string().min(10, {
message:
t("contact.form.validation.messageLength") ||
"Message must be at least 10 characters.",
}),
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
name: "",
email: "",
message: "",
},
});
async function executeRecaptcha(): Promise<string | null> {
if (!recaptchaReady || !window.grecaptcha) {
throw new Error("reCAPTCHA not ready");
}
return new Promise((resolve, reject) => {
window.grecaptcha.ready(() => {
window.grecaptcha
.execute(process.env.NEXT_PUBLIC_RECAPTCHA_SITE_KEY!, {
action: "contact_form",
})
.then(resolve)
.catch(reject);
});
});
}
function onSubmit(values: z.infer<typeof formSchema>) {
startTransition(async () => {
try {
// Execute reCAPTCHA first
setIsRecaptchaLoading(true);
const recaptchaToken = await executeRecaptcha();
setIsRecaptchaLoading(false);
if (!recaptchaToken) {
toast.error(
t("contact.form.recaptchaError") ||
"Security verification failed. Please try again."
);
return;
}
const result = await submitContactForm(values, recaptchaToken);
if (result.success) {
form.reset();
toast.success(
t("contact.form.successMessage") ||
"Your message has been sent successfully!",
{
description: "We'll get back to you as soon as possible.",
}
);
} else {
toast.error(
result.message ||
t("contact.form.errorMessage") ||
"Failed to send your message. Please try again."
);
// Your existing validation error handling
if (result.errors) {
result.errors.forEach((error) => {
form.setError(error.path as any, {
type: "server",
message: error.message,
});
});
}
}
} catch (error) {
console.error("Error submitting form:", error);
setIsRecaptchaLoading(false);
toast.error(
t("contact.form.errorMessage") ||
"An unexpected error occurred. Please try again later."
);
}
});
}
return (
<>
<Script
src={`https://www.google.com/recaptcha/api.js?render=${process.env.NEXT_PUBLIC_RECAPTCHA_SITE_KEY}`}
onLoad={() => {
if (window.grecaptcha) {
window.grecaptcha.ready(() => {
setRecaptchaReady(true);
});
}
}}
/>
<Form {...form}>
<form
id="contact"
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-6 bg-white p-4 rounded-lg shadow-sm"
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>{t("contact.form.name")}</FormLabel>
<FormControl>
<Input
placeholder={
t("contact.form.namePlaceholder") || "John Doe"
}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>{t("contact.form.email")}</FormLabel>
<FormControl>
<Input
placeholder={
t("contact.form.emailPlaceholder") || "john@example.com"
}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="message"
render={({ field }) => (
<FormItem>
<FormLabel>{t("contact.form.message")}</FormLabel>
<FormControl>
<Textarea
placeholder={
t("contact.form.messagePlaceholder") ||
"Your message here..."
}
className="h-24 resize-none"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
disabled={isPending || isRecaptchaLoading || !recaptchaReady}
>
{isPending || isRecaptchaLoading ? (
<>
<svg
className="animate-spin -ml-1 mr-3 h-5 w-5 text-white"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
{isRecaptchaLoading
? t("contact.form.verifying") || "Verifying..."
: t("contact.form.sending") || "Sending..."}
</>
) : (
t("contact.form.submit") || "Submit"
)}
</Button>
</form>
</Form>
</>
);
}
The genius of this approach is that it preserves your entire existing form structure while adding security as a preprocessing step. The reCAPTCHA execution happens invisibly before form submission, and the loading states provide clear feedback about what's happening. Your internationalization, validation patterns, and error handling all continue working exactly as before.
Key Implementation Details
The executeRecaptcha
function handles the communication with Google's client-side API. The action name 'contact_form' should be descriptive and match what you verify on the server side. This helps Google's machine learning understand the context of each request.
The loading states are crucial for user experience. The button shows "Verifying..." during reCAPTCHA execution and "Sending..." during actual form submission. This transparency helps users understand that something is happening even though reCAPTCHA v3 is invisible.
The Script component from Next.js ensures the reCAPTCHA library loads optimally, and the onLoad callback sets up the ready state properly. The button stays disabled until reCAPTCHA is fully initialized, preventing premature submissions.
Why This Approach Works
This implementation succeeds because it treats reCAPTCHA as a security layer rather than a form rebuilding project. By executing reCAPTCHA verification as the first step in your existing submission flow, you can add enterprise-level bot protection without touching any of your working form logic.
The score-based system of reCAPTCHA v3 provides much better user experience than traditional CAPTCHAs while offering sophisticated bot detection. Users never see a challenge, and legitimate traffic flows seamlessly while suspicious behavior gets filtered out.
Your forms now have invisible security that protects against automated attacks while preserving every aspect of your existing user experience. The implementation is production-ready and maintains all the patterns your team already knows how to work with.
Let me know in the comments if you have questions, and subscribe for more practical development guides.
Thanks, Matija