Next Starter Logo
Components

Forms

How Next Starter handles forms. Zod 4 validation schemas, Server Actions with reCAPTCHA, and React Hook Form client components wired together with shadcn/ui primitives.

Overview

Forms in Next Starter follow a consistent three-part pattern:

  1. Validation schema: a Zod 4 schema in lib/validations/
  2. Server Action: a "use server" function in app/actions/
  3. Form component: a "use client" component using React Hook Form

This separation keeps validation logic co-located with types, keeps mutations on the server, and keeps the form UI cleanly decoupled from business logic.

Validation Schemas

All Zod schemas live in lib/validations/. Each file exports the schema, its inferred TypeScript type, and any response types.

// lib/validations/contact.ts
import { z } from "zod";
import type { ApiResponse } from "@/types/api";

export const contactSchema = z.object({
  name: z.string().min(1, "Name is required"),
  email: z.email("Please enter a valid email address"),
  message: z.string().min(10, "Message must be at least 10 characters"),
});

export type ContactFormData = z.infer<typeof contactSchema>;
export type ContactFormResponse = ApiResponse;

Note: this project uses Zod 4. The z.email() method is a Zod 4 API. Do not use z.string().email() from Zod 3.

Server Actions

Server Actions handle mutations. They are plain async functions marked with "use server" and live in app/actions/.

A Server Action receives FormData, validates the reCAPTCHA token, validates input using the Zod schema, performs the mutation, and returns a typed response object. It never throws to the client.

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

import { after } from "next/server";
import { sendEmail, getContactFormEmailHtml } from "@/lib/email";
import { APP_CONFIG } from "@/lib/config";
import {
  contactSchema,
  type ContactFormResponse,
} from "@/lib/validations/contact";
import { logger } from "@/lib/logger";
import { validateRecaptcha } from "./recaptcha";

export async function submitContactForm(
  formData: FormData,
): Promise<ContactFormResponse> {
  try {
    const recaptchaToken = formData.get("recaptcha_token") as string | null;
    if (recaptchaToken) {
      const isValid = await validateRecaptcha(recaptchaToken);
      if (!isValid) {
        return { success: false, error: "reCAPTCHA verification failed" };
      }
    } else if (
      process.env.NODE_ENV === "production" &&
      process.env.RECAPTCHA_SECRET_KEY
    ) {
      return { success: false, error: "reCAPTCHA verification required" };
    }

    const { name, email, message } = contactSchema.parse({
      name: formData.get("name"),
      email: formData.get("email"),
      message: formData.get("message"),
    });

    const emailResult = await sendEmail({
      to: APP_CONFIG.email,
      subject: `New contact form submission from ${name}`,
      html: getContactFormEmailHtml(name, email, message),
    });

    if (!emailResult.success) {
      return { success: false, error: "Failed to send message" };
    }

    return { success: true };
  } catch (error) {
    after(() => {
      logger.error(
        { event: "contact_form_failure", err: error },
        "Contact form submission failed",
      );
    });
    return { success: false, error: "Failed to send message" };
  }
}

Error handling conventions

  • Catch all errors inside the action. Never let them propagate as unhandled rejections.
  • Return { success: false, error: "..." } for actionable failures.
  • Use after() from next/server to log errors asynchronously after the response is sent. This avoids blocking the response on logging.

Building the Form Component

The form component is a Client Component that wires React Hook Form to the Server Action. Import useForm and zodResolver, along with the shadcn/ui Form primitives from components/ui/form.

Because the contact form uses reCAPTCHA v3, the exported component wraps the inner form in ReCaptchaProvider from next-recaptcha-v3. The inner component calls executeRecaptcha before submitting and appends the token to FormData so the Server Action can verify it.

// app/(site)/contact/contact-form.tsx
"use client";

import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { AlertCircle, Check } from "lucide-react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { ReCaptchaProvider, useReCaptcha } from "next-recaptcha-v3";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { submitContactForm } from "@/app/actions/contact";
import { contactSchema } from "@/lib/validations/contact";
import {
  Form,
  FormControl,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from "@/components/ui/form";

type FormValues = z.infer<typeof contactSchema>;

function ContactFormInner() {
  const [error, setError] = useState("");
  const [isSubmitting, setIsSubmitting] = useState(false);
  const [isSuccess, setIsSuccess] = useState(false);
  const { executeRecaptcha } = useReCaptcha();

  const form = useForm<FormValues>({
    resolver: zodResolver(contactSchema),
    defaultValues: {
      name: "",
      email: "",
      message: "",
    },
  });

  const handleSubmit = async (values: FormValues) => {
    setError("");
    setIsSubmitting(true);

    try {
      const token = await executeRecaptcha("contact_form");

      if (!token) {
        setError("reCAPTCHA verification failed. Please refresh and try again.");
        return;
      }

      const formData = new FormData();
      formData.append("name", values.name);
      formData.append("email", values.email);
      formData.append("message", values.message);
      formData.append("recaptcha_token", token);

      const response = await submitContactForm(formData);

      if (!response.success) {
        setError(response.error || "Failed to send message");
        return;
      }

      setIsSuccess(true);
      form.reset();
    } catch {
      setError("Something went wrong. Please try again later.");
    } finally {
      setIsSubmitting(false);
    }
  };

  if (isSuccess) {
    return (
      <div className="bg-success/10 border border-success rounded-lg p-8 text-center max-w-2xl mx-auto animate-fade-in">
        <div className="mx-auto mb-4 flex size-12 items-center justify-center rounded-full bg-success/20">
          <Check className="size-6 text-success" strokeWidth={2.5} />
        </div>
        <h2 className="text-2xl font-semibold text-success mb-2">
          Message sent
        </h2>
        <p className="text-success/80">
          Thanks for reaching out. We&apos;ll get back to you soon.
        </p>
      </div>
    );
  }

  return (
    <div className="max-w-2xl mx-auto">
      {error && (
        <Alert variant="destructive" className="mb-6">
          <AlertCircle className="h-4 w-4" />
          <AlertTitle>Error</AlertTitle>
          <AlertDescription>{error}</AlertDescription>
        </Alert>
      )}

      <Form {...form}>
        <form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6">
          <FormField
            control={form.control}
            name="name"
            render={({ field }) => (
              <FormItem>
                <FormLabel>Name</FormLabel>
                <FormControl>
                  <Input
                    placeholder="Your name"
                    disabled={isSubmitting}
                    autoComplete="name"
                    {...field}
                  />
                </FormControl>
                <FormMessage />
              </FormItem>
            )}
          />

          <FormField
            control={form.control}
            name="email"
            render={({ field }) => (
              <FormItem>
                <FormLabel>Email</FormLabel>
                <FormControl>
                  <Input
                    type="email"
                    placeholder="your.email@example.com"
                    disabled={isSubmitting}
                    autoComplete="email"
                    {...field}
                  />
                </FormControl>
                <FormMessage />
              </FormItem>
            )}
          />

          <FormField
            control={form.control}
            name="message"
            render={({ field }) => (
              <FormItem>
                <FormLabel>Message</FormLabel>
                <FormControl>
                  <Textarea
                    placeholder="Tell us what's on your mind"
                    rows={6}
                    disabled={isSubmitting}
                    {...field}
                  />
                </FormControl>
                <FormMessage />
              </FormItem>
            )}
          />

          <Button type="submit" disabled={isSubmitting} className="w-full">
            {isSubmitting ? (
              <>
                <Spinner /> Sending
              </>
            ) : (
              "Send Message"
            )}
          </Button>
        </form>
      </Form>
    </div>
  );
}

export function ContactForm() {
  return (
    <ReCaptchaProvider reCaptchaKey={process.env.NEXT_PUBLIC_RECAPTCHA_SITE_KEY!}>
      <ContactFormInner />
    </ReCaptchaProvider>
  );
}

Key points

  • useForm is initialized with resolver: zodResolver(contactSchema), which runs client-side validation before the action is called.
  • FormField wraps each input and connects it to the form control via the render prop.
  • FormMessage renders the Zod validation error for that field automatically.
  • isSubmitting state is managed manually so the button stays disabled for the full async sequence, including the reCAPTCHA call.
  • executeRecaptcha is called before building FormData. If it returns a falsy value, the submission aborts client-side before reaching the server.

Form Primitives

The components/ui/form.tsx component is a thin wrapper around React Hook Form's context API. The building blocks are:

ComponentPurpose
FormProvides form context; wrap the <form> element with this
FormFieldConnects a named field to the form state
FormItemLayout wrapper for label + input + message
FormLabelAccessible label, turns red on error
FormControlSlot for the input element
FormDescriptionOptional hint text below the input
FormMessageDisplays the validation error for the field

Adding a New Form

  1. Add a schema to lib/validations/your-feature.ts
  2. Add a Server Action to app/actions/your-feature.ts
  3. Create a Client Component form using useForm + zodResolver
  4. Call the action in handleSubmit and handle the typed response

Keeping schemas in lib/validations/ means the same Zod types can be reused across the action, the form component, and any API routes that deal with the same data.

Schema Reference

All form-related validation schemas in lib/validations/:

auth.ts

SchemaPurpose
registerSchemaRegistration form (name, email, password)
loginSchemaSign-in form (email, password)
forgotPasswordSchemaForgot password form (email)
emailVerificationSchemaEmail verification (email)
tokenVerificationSchemaToken verification (token, email)
emailChangeVerificationSchemaEmail change verification (token, email)
resetPasswordSchemaReset password (password, confirmPassword with match refinement)
changePasswordSchemaChange password (currentPassword, newPassword, confirmPassword with match refinement)

user.ts

SchemaPurpose
updateProfileSchemaProfile update (name)
changeEmailSchemaEmail change (currentEmail, newEmail, callbackURL)
createUserSchemaAdmin user creation (name, email, password, role)
updateUserByAdminSchemaAdmin user edit (name, email, role, banned, banReason, banExpires)
avatarFilenameSchemaAvatar filename validation
avatarExtensionSchemaAvatar file extension validation

settings.ts

SchemaPurpose
settingsSchemaUser notification settings (notifications.productUpdates, notifications.marketingEmails)

files.ts

SchemaPurpose
folderSchemaFolder creation (name)
fileUploadSchemaFile upload validation (filename, contentType)

contact.ts

SchemaPurpose
contactSchemaContact form (name, email, message)

On this page