---
title: "How to Add reCAPTCHA v3 to Your Next.js 15.5 Contact Form Without Breaking Anything"
slug: "recaptcha-v3-nextjs-guide"
published: "2025-09-27"
updated: "2025-09-23"
categories:
  - "Next.js"
tags:
  - "recaptcha v3"
  - "next.js 15"
  - "contact form"
  - "bot protection"
  - "react hook form"
  - "security"
  - "server actions"
  - "invisible captcha"
llm-intent: "reference"
audience-level: "intermediate"
framework-versions:
  - "next.js@15.5"
status: "stable"
llm-purpose: "Learn to integrate reCAPTCHA v3 with Next.js 15.5 contact forms while maintaining React Hook Form, Zod validation, and internationalization features."
llm-prereqs:
  - "Access to Next.js"
  - "Access to reCAPTCHA v3"
  - "Access to React Hook Form"
  - "Access to Zod"
  - "Access to TypeScript"
llm-outputs:
  - "Completed outcome: Learn to integrate reCAPTCHA v3 with Next.js 15.5 contact forms while maintaining React Hook Form, Zod validation, and internationalization features."
---

**Summary Triples**
- (Environment, requires, NEXT_PUBLIC_RECAPTCHA_SITE_KEY and RECAPTCHA_SECRET_KEY in .env (site key client-visible, secret server-only))
- (Client integration, should, load grecaptcha and call grecaptcha.execute('site_key', { action }) to obtain a token before submission)
- (Form flow, preserves, existing React Hook Form + Zod validation; token is layered onto existing submit without replacing validation)
- (Server verification, uses, POST to https://www.google.com/recaptcha/api/siteverify with secret + token and validates success and score)
- (Server Action, must, accept the recaptcha token alongside form fields, call verify utility, and only proceed if token verification passes score threshold)
- (Score threshold, recommendation, start with 0.5 and tune based on traffic; treat lower scores as suspicious)
- (Error handling, should, show user-friendly i18n toast messages on verification failure and log details server-side for analysis)
- (Security, enforce, keep the secret key strictly server-side and never expose it in client bundles)
- (Testing, advise, add localhost and dev domains in reCAPTCHA admin, monitor requests in reCAPTCHA console, and use a permissive threshold for development)
- (Non-breaking approach, strategy, layer token acquisition and verification on top of existing submit handler (e.g., call execute, then call handleSubmit))

### {GOAL}
Learn to integrate reCAPTCHA v3 with Next.js 15.5 contact forms while maintaining React Hook Form, Zod validation, and internationalization features.

### {PREREQS}
- Access to Next.js
- Access to reCAPTCHA v3
- Access to React Hook Form
- Access to Zod
- Access to TypeScript

### {STEPS}
1. Set up environment variables
2. Create verification utility
3. Update server action
4. Integrate client-side reCAPTCHA
5. Test security implementation

<!-- llm:goal="Learn to integrate reCAPTCHA v3 with Next.js 15.5 contact forms while maintaining React Hook Form, Zod validation, and internationalization features." -->
<!-- llm:prereq="Access to Next.js" -->
<!-- llm:prereq="Access to reCAPTCHA v3" -->
<!-- llm:prereq="Access to React Hook Form" -->
<!-- llm:prereq="Access to Zod" -->
<!-- llm:prereq="Access to TypeScript" -->
<!-- llm:output="Completed outcome: Learn to integrate reCAPTCHA v3 with Next.js 15.5 contact forms while maintaining React Hook Form, Zod validation, and internationalization features." -->

# How to Add reCAPTCHA v3 to Your Next.js 15.5 Contact Form Without Breaking Anything
> Learn to integrate reCAPTCHA v3 with Next.js 15.5 contact forms while maintaining React Hook Form, Zod validation, and internationalization features.
Matija Žiberna · 2025-09-27

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_`:

```bash
# 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:

```typescript
// 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:

```typescript
// 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:

```typescript
// 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

## LLM Response Snippet
```json
{
  "goal": "Learn to integrate reCAPTCHA v3 with Next.js 15.5 contact forms while maintaining React Hook Form, Zod validation, and internationalization features.",
  "responses": [
    {
      "question": "What does the article \"How to Add reCAPTCHA v3 to Your Next.js 15.5 Contact Form Without Breaking Anything\" cover?",
      "answer": "Learn to integrate reCAPTCHA v3 with Next.js 15.5 contact forms while maintaining React Hook Form, Zod validation, and internationalization features."
    }
  ]
}
```