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, getContactFormEmail } 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}`,
      replyTo: email,
      ...(await getContactFormEmail(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 Controller from react-hook-form, zodResolver, and the shadcn/ui Field primitives from components/ui/field.

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 { Controller, 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 {
  Field,
  FieldError,
  FieldGroup,
  FieldLabel,
} from "@/components/ui/field";

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 onSubmit={form.handleSubmit(handleSubmit)}>
        <FieldGroup>
          <Controller
            control={form.control}
            name="name"
            render={({ field, fieldState }) => (
              <Field data-invalid={fieldState.invalid || undefined}>
                <FieldLabel htmlFor={field.name}>Name</FieldLabel>
                <Input
                  {...field}
                  id={field.name}
                  placeholder="Your name"
                  disabled={isSubmitting}
                  autoComplete="name"
                  aria-invalid={fieldState.invalid || undefined}
                />
                {fieldState.invalid && (
                  <FieldError errors={[fieldState.error]} />
                )}
              </Field>
            )}
          />

          <Controller
            control={form.control}
            name="email"
            render={({ field, fieldState }) => (
              <Field data-invalid={fieldState.invalid || undefined}>
                <FieldLabel htmlFor={field.name}>Email</FieldLabel>
                <Input
                  {...field}
                  id={field.name}
                  type="email"
                  placeholder="your.email@example.com"
                  disabled={isSubmitting}
                  autoComplete="email"
                  aria-invalid={fieldState.invalid || undefined}
                />
                {fieldState.invalid && (
                  <FieldError errors={[fieldState.error]} />
                )}
              </Field>
            )}
          />

          <Controller
            control={form.control}
            name="message"
            render={({ field, fieldState }) => (
              <Field data-invalid={fieldState.invalid || undefined}>
                <FieldLabel htmlFor={field.name}>Message</FieldLabel>
                <Textarea
                  {...field}
                  id={field.name}
                  placeholder="Tell us what's on your mind"
                  rows={6}
                  disabled={isSubmitting}
                  aria-invalid={fieldState.invalid || undefined}
                />
                {fieldState.invalid && (
                  <FieldError errors={[fieldState.error]} />
                )}
              </Field>
            )}
          />

          <Button type="submit" disabled={isSubmitting} className="w-full">
            {isSubmitting ? (
              <>
                <Spinner /> Sending
              </>
            ) : (
              "Send Message"
            )}
          </Button>
        </FieldGroup>
      </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.
  • Controller from react-hook-form binds each named field to the form state via its render prop and exposes fieldState for displaying validation errors.
  • FieldError takes an errors={[fieldState.error]} prop and renders the Zod validation message; the surrounding Field element and the input both flip their visual/ARIA state via data-invalid / aria-invalid.
  • 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.

Field Primitives

The components/ui/field.tsx component exports the composable building blocks used together with Controller from react-hook-form:

ComponentPurpose
FieldLayout wrapper for label + input + error; toggles error styling via data-invalid
FieldGroupStacks multiple Fields with consistent spacing
FieldLabelAccessible label for the field
FieldErrorDisplays the validation error message
FieldSet / FieldLegendGroup related fields with a shared legend (e.g. radio groups)

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)
verifyEmailOtpSchemaVerify-email form (6-digit OTP code)
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, role, sendEmail)
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