Next.js Contact Form: Zod, useActionState & Sonner

Build a resilient Next.js contact form with Zod v4, reCAPTCHA, server actions and Sonner toast feedback.

·Matija Žiberna·
Next.js Contact Form: Zod, useActionState & Sonner

⚡ 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.

No spam. Unsubscribe anytime.

I was building a contact form for a client project when I realized that server actions alone don't guarantee user feedback. The form would submit, validation would fail silently, and users had no idea what happened. After implementing the complete solution with proper error handling and toast notifications, I'm sharing exactly how to build this correctly.

The Problem

Modern contact forms need to do several things simultaneously: verify security (reCAPTCHA), validate input on the server, send emails, and most importantly, tell the user what's happening at every step. Most implementations handle the happy path but fail when validation errors occur or when the component isn't properly wired up. The gotchas are subtle but critical.

Setting Up the Foundation

Before diving into code, you need the core dependencies installed. Make sure you have shadcn/ui initialized in your project, then add Sonner:

npm install sonner zod next-themes
npx shadcn-ui@latest add button input textarea checkbox field

The Toaster component from Sonner is essential but easy to overlook. This is where many implementations fail - developers use toast() functions but never mount the component that renders them.

Step 1: Mount the Toaster in Your Root Layout

This is the first critical step. Without this, all your toast notifications will be called but nothing will appear on screen.

// File: app/layout.tsx
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { Toaster } from "sonner";
import Navbar from "@/app/components/navbar";
import Footer from "@/app/components/footer";
import "./globals.css";

const geistSans = Geist({
  variable: "--font-geist-sans",
  subsets: ["latin"],
});

const geistMono = Geist_Mono({
  variable: "--font-geist-mono",
  subsets: ["latin"],
});

export const metadata: Metadata = {
  title: "My App",
  description: "Contact form with proper feedback",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html>
      <body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
        <Navbar />
        {children}
        <Footer />
        <Toaster />
      </body>
    </html>
  );
}

The Toaster component needs to be in your root layout, not in individual components. This single component handles all toast notifications for your entire application. Without it mounted here, calling toast.success() or toast.error() will do nothing.

Step 2: Create Your Validation Schema with Zod v4

Define a schema that describes exactly what your form expects. Zod v4 provides excellent error messages out of the box.

// File: app/actions/schemas.ts
import { z } from "zod";

export const contactFormSchema = z.object({
  name: z
    .string()
    .min(2, "Name must be at least 2 characters")
    .max(100, "Name must be less than 100 characters"),
  email: z
    .string()
    .email({ message: "Please enter a valid email address" })
    .max(255, "Email must be less than 255 characters"),
  message: z
    .string()
    .min(10, "Message must be at least 10 characters")
    .max(5000, "Message must be less than 5000 characters"),
  agree: z
    .boolean()
    .refine((val) => val === true, "You must agree to the terms of service"),
});

export type ContactFormInput = z.infer<typeof contactFormSchema>;

This schema validates every field and provides specific error messages. The agree field uses .refine() to ensure it's explicitly true. This is important because form data handling can be tricky - you'll see why in the server action.

Step 3: Build the Server Action with Complete Error Handling

This is where the magic happens. The server action validates input, sends emails, and returns structured responses that the client can act on.

// File: app/actions/contact.ts
"use server";

import { z } from "zod";
import { contactFormSchema } from "@/app/actions/schemas";
import { sendBrevoEmail, sendConfirmationEmail } from "@/app/services/brevo";
import { verifyRecaptcha } from "@/app/services/recaptcha";

const actionMessages = {
  contact: {
    success: "Your message has been sent successfully! We'll get back to you soon.",
    validationError: "Please check your form and try again.",
    emailError: "Failed to send email. Please try again later.",
    serverError: "An unexpected error occurred. Please try again later.",
  },
};

export async function submitContact(
  _prevState: unknown,
  formData: FormData
) {
  try {
    console.log("[CONTACT] Form submission started");

    // Extract and verify reCAPTCHA token
    const recaptchaToken = formData.get("recaptcha_token") as string;
    console.log("[CONTACT] reCAPTCHA token received:", !!recaptchaToken);

    if (!recaptchaToken) {
      console.warn("[CONTACT] reCAPTCHA token missing");
      return {
        status: "error" as const,
        message: "Security verification missing. Please try again.",
      };
    }

    const recaptchaResult = await verifyRecaptcha(recaptchaToken);
    console.log("[CONTACT] reCAPTCHA verification result:", recaptchaResult.success);

    if (!recaptchaResult.success) {
      console.warn("[CONTACT] reCAPTCHA verification failed:", recaptchaResult.error);
      return {
        status: "error" as const,
        message: recaptchaResult.error || "Security verification failed. Please try again.",
      };
    }

    // Extract form data - note the checkbox handling
    const agreeValue = formData.get("agree");
    const data = {
      name: formData.get("name"),
      email: formData.get("email"),
      message: formData.get("message"),
      agree: agreeValue === "on", // FormData sends "on" for checked, "off" or null for unchecked
    };
    console.log("[CONTACT] Form data extracted - Name:", data.name, "Email:", data.email, "Agree:", data.agree);

    // Validate with Zod
    const validatedData = contactFormSchema.parse(data);
    console.log("[CONTACT] Form data validated successfully");

    // Send emails
    console.log("[CONTACT] Sending admin notification email");
    await sendBrevoEmail({
      to: "admin@example.com",
      toName: "Admin Team",
      name: validatedData.name,
      email: validatedData.email,
      message: validatedData.message,
      replyTo: validatedData.email, // This allows admin to reply directly to customer
    });
    console.log("[CONTACT] Admin notification email sent successfully");

    console.log("[CONTACT] Sending confirmation email to customer");
    await sendConfirmationEmail(validatedData.name, validatedData.email);
    console.log("[CONTACT] Confirmation email sent successfully");

    console.log("[CONTACT] Form submission completed successfully");
    return {
      status: "success" as const,
      message: actionMessages.contact.success,
      data: validatedData,
    };
  } catch (error) {
    // Handle Zod validation errors with specific field information
    if (error instanceof z.ZodError) {
      console.error("[CONTACT] Zod validation error - full object:", error);
      const validationErrors = error.issues
        .map(issue => `${issue.path.join('.')}: ${issue.message}`)
        .join(', ');
      console.error("[CONTACT] Formatted validation errors:", validationErrors);
      return {
        status: "error" as const,
        message: `Validation error: ${validationErrors}`,
      };
    }

    // Handle email sending errors
    if (error instanceof Error && error.message.includes("Failed to send email")) {
      console.error("[CONTACT] Email sending error:", error.message);
      return {
        status: "error" as const,
        message: actionMessages.contact.emailError,
      };
    }

    // Handle unexpected errors
    console.error("[CONTACT] Unexpected error:", error);
    return {
      status: "error" as const,
      message: actionMessages.contact.serverError,
    };
  }
}

Notice the checkbox handling: FormData sends the string "on" when a checkbox is checked, not a boolean. This trips up many developers. We check agreeValue === "on" to convert it to the boolean the schema expects.

The logging at each step is crucial for debugging. When something goes wrong in production, these logs tell you exactly where the pipeline broke.

Step 4: Build the Contact Form Component

This is where useActionState orchestrates the entire flow, and where Sonner displays feedback.

// File: app/components/contact.tsx
"use client";

import { useActionState, useEffect, useState, useTransition, useRef } from "react";
import Script from "next/script";
import { Mail, Phone, MapPin, Loader } from "lucide-react";
import { toast } from "sonner";
import { submitContact } from "@/app/actions/contact";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Checkbox } from "@/components/ui/checkbox";
import { Field, FieldLabel } from "@/components/ui/field";

declare global {
  interface Window {
    grecaptcha: {
      ready: (callback: () => void) => void;
      execute: (
        siteKey: string,
        options: { action: string }
      ) => Promise<string>;
    };
  }
}

interface ContactProps {
  contactInfo: Array<{ label: string; value: string }>;
  formLabels: {
    nameLabel: string;
    namePlaceholder: string;
    emailLabel: string;
    emailPlaceholder: string;
    messageLabel: string;
    messagePlaceholder: string;
    checkboxLabel: string;
    submitLabel: string;
  };
}

export default function Contact({ contactInfo, formLabels }: ContactProps) {
  const [formData, setFormData] = useState({
    name: "",
    email: "",
    message: "",
    agree: false,
  });

  const [recaptchaReady, setRecaptchaReady] = useState(false);
  const [isVerifying, setIsVerifying] = useState(false);
  const [isPending, startTransition] = useTransition();
  const lastStateRef = useRef<unknown>(null);

  const [state, formAction] = useActionState(submitContact, null);

  // Show toast feedback when state changes
  useEffect(() => {
    console.log("[CONTACT_COMPONENT] State changed:", state);

    if (!state || state === lastStateRef.current) {
      console.log("[CONTACT_COMPONENT] State is null or unchanged, skipping toast");
      return;
    }

    lastStateRef.current = state;
    console.log("[CONTACT_COMPONENT] Showing toast for status:", state.status);

    if (state.status === "success") {
      console.log("[CONTACT_COMPONENT] Showing success toast:", state.message);
      toast.success(state.message);
      // Reset form after successful submission
      const timeoutId = setTimeout(() => {
        setFormData({
          name: "",
          email: "",
          message: "",
          agree: false,
        });
      }, 0);
      return () => clearTimeout(timeoutId);
    } else if (state.status === "error") {
      console.log("[CONTACT_COMPONENT] Showing error toast:", state.message);
      toast.error(state.message);
    }
  }, [state]);

  const handleChange = (
    e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
  ) => {
    const { name, value } = e.target;
    setFormData((prev) => ({
      ...prev,
      [name]: value,
    }));
  };

  const handleCheckboxChange = (checked: boolean) => {
    setFormData((prev) => ({
      ...prev,
      agree: checked,
    }));
  };

  const executeRecaptcha = async (): Promise<string | null> => {
    if (!recaptchaReady || !window.grecaptcha) {
      console.error("[CONTACT_COMPONENT] reCAPTCHA not ready");
      return null;
    }

    try {
      const token = await window.grecaptcha.execute(
        process.env.NEXT_PUBLIC_RECAPTCHA_SITE_KEY!,
        { action: "contact_form" }
      );
      return token;
    } catch (error) {
      console.error("[CONTACT_COMPONENT] reCAPTCHA execution error:", error);
      return null;
    }
  };

  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    console.log("[CONTACT_COMPONENT] Form submission initiated");

    // Execute reCAPTCHA verification
    console.log("[CONTACT_COMPONENT] Starting reCAPTCHA verification");
    setIsVerifying(true);
    const recaptchaToken = await executeRecaptcha();
    setIsVerifying(false);
    console.log("[CONTACT_COMPONENT] reCAPTCHA token received:", !!recaptchaToken);

    if (!recaptchaToken) {
      console.error("[CONTACT_COMPONENT] reCAPTCHA token is null");
      toast.error("Security verification failed. Please try again.");
      return;
    }

    // Create FormData with correct checkbox handling
    const formDataObj = new FormData();
    formDataObj.append("name", formData.name);
    formDataObj.append("email", formData.email);
    formDataObj.append("message", formData.message);
    formDataObj.append("agree", formData.agree ? "on" : "off"); // Key: use "on"/"off" convention
    formDataObj.append("recaptcha_token", recaptchaToken);
    console.log("[CONTACT_COMPONENT] FormData prepared - agree:", formData.agree ? "on" : "off");

    // Call server action
    startTransition(() => {
      console.log("[CONTACT_COMPONENT] startTransition callback executing");
      formAction(formDataObj);
    });
  };

  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);
            });
          }
        }}
      />

      <section id="contact" className="bg-primary px-4 py-12 sm:px-6 sm:py-16">
        <div className="mx-auto max-w-5xl">
          <div className="flex flex-col gap-12 md:gap-20 md:flex-row">
            {/* Contact Information */}
            <div className="flex-1">
              <div className="flex flex-col gap-6">
                {contactInfo.map((info, idx) => (
                  <div key={idx} className="flex items-start gap-4">
                    <p className="text-sm text-primary-foreground">{info.value}</p>
                  </div>
                ))}
              </div>
            </div>

            {/* Contact Form */}
            <div className="flex-1 w-full">
              <form onSubmit={handleSubmit} className="flex flex-col gap-4 sm:gap-6">
                <Field>
                  <FieldLabel className="text-primary-foreground">
                    {formLabels.nameLabel}
                  </FieldLabel>
                  <Input
                    name="name"
                    value={formData.name}
                    onChange={handleChange}
                    placeholder={formLabels.namePlaceholder}
                    className="bg-white text-black border-0"
                    required
                    disabled={isPending || isVerifying}
                  />
                </Field>

                <Field>
                  <FieldLabel className="text-primary-foreground">
                    {formLabels.emailLabel}
                  </FieldLabel>
                  <Input
                    name="email"
                    type="email"
                    value={formData.email}
                    onChange={handleChange}
                    placeholder={formLabels.emailPlaceholder}
                    className="bg-white text-black border-0"
                    required
                    disabled={isPending || isVerifying}
                  />
                </Field>

                <Field>
                  <FieldLabel className="text-primary-foreground">
                    {formLabels.messageLabel}
                  </FieldLabel>
                  <Textarea
                    name="message"
                    value={formData.message}
                    onChange={handleChange}
                    placeholder={formLabels.messagePlaceholder}
                    className="min-h-[140px] bg-white text-black border-0"
                    required
                    disabled={isPending || isVerifying}
                  />
                </Field>

                <div className="flex items-center gap-3">
                  <Checkbox
                    id="agree"
                    checked={formData.agree}
                    onCheckedChange={handleCheckboxChange}
                    disabled={isPending || isVerifying}
                  />
                  <label
                    htmlFor="agree"
                    className="text-sm font-medium text-primary-foreground cursor-pointer"
                  >
                    {formLabels.checkboxLabel}
                  </label>
                </div>

                <Button
                  type="submit"
                  disabled={!formData.agree || isPending || isVerifying || !recaptchaReady}
                  className="mt-2"
                >
                  {isVerifying ? (
                    <>
                      <Loader className="animate-spin mr-2 h-4 w-4" />
                      Verifying...
                    </>
                  ) : isPending ? (
                    <>
                      <Loader className="animate-spin mr-2 h-4 w-4" />
                      Sending...
                    </>
                  ) : !recaptchaReady ? (
                    "Loading..."
                  ) : (
                    formLabels.submitLabel
                  )}
                </Button>
              </form>
            </div>
          </div>
        </div>
      </section>
    </>
  );
}

The component uses useActionState to connect the form to the server action. The lastStateRef prevents duplicate toasts - without it, React's re-renders could trigger the same toast multiple times. The key insight is that the effect watches state, and when it changes from the server action, we display the appropriate toast.

Understanding the Key Gotchas

There are three critical gotchas that trip up most developers:

Toaster Component Must Be Mounted: The <Toaster /> component is just an empty container in your layout. Without it, all toast() calls silently fail. This is the number one reason contact forms appear broken - the logic works, but there's no feedback.

FormData Checkbox Convention: HTML forms send "on" for checked checkboxes, not true. When you do formData.append("agree", String(formData.agree)), you get "true" or "false", which doesn't match === "on". Always use formData.agree ? "on" : "off".

useRef for Duplicate Prevention: Without tracking the previous state, the same toast can fire multiple times because React might render the component multiple times. Using useRef to store the last state value prevents this.

Conclusion

Building a contact form that actually tells users what's happening requires wiring together several pieces correctly: a mounted Toaster component, proper form data handling, comprehensive error messages, and state management with useActionState. The key is understanding that each piece must work perfectly for the user to see feedback. A silent failure in any one step means the user gets no notification.

By following this implementation, you now know how to build a contact form with proper user feedback at every stage. You understand the gotchas that trip up developers and how to avoid them. The comprehensive logging in both the server action and component will help you debug any issues in production.

Let me know in the comments if you have questions about any of these concepts, and subscribe for more practical development guides.

Thanks, Matija

0

Frequently Asked Questions

Comments

Leave a Comment

Your email will not be published

10-2000 characters

• Comments are automatically approved and will appear immediately

• Your name and email will be saved for future comments

• Be respectful and constructive in your feedback

• No spam, self-promotion, or off-topic content

Matija Žiberna
Matija Žiberna
Full-stack developer, co-founder

I'm Matija Žiberna, a self-taught full-stack developer and co-founder passionate about building products, writing clean code, and figuring out how to turn ideas into businesses. I write about web development with Next.js, lessons from entrepreneurship, and the journey of learning by doing. My goal is to provide value through code—whether it's through tools, content, or real-world software.