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:
- Validation schema: a Zod 4 schema in
lib/validations/ - Server Action: a
"use server"function inapp/actions/ - 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()fromnext/serverto 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'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
useFormis initialized withresolver: zodResolver(contactSchema), which runs client-side validation before the action is called.Controllerfromreact-hook-formbinds each named field to the form state via itsrenderprop and exposesfieldStatefor displaying validation errors.FieldErrortakes anerrors={[fieldState.error]}prop and renders the Zod validation message; the surroundingFieldelement and the input both flip their visual/ARIA state viadata-invalid/aria-invalid.isSubmittingstate is managed manually so the button stays disabled for the full async sequence, including the reCAPTCHA call.executeRecaptchais called before buildingFormData. 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:
| Component | Purpose |
|---|---|
Field | Layout wrapper for label + input + error; toggles error styling via data-invalid |
FieldGroup | Stacks multiple Fields with consistent spacing |
FieldLabel | Accessible label for the field |
FieldError | Displays the validation error message |
FieldSet / FieldLegend | Group related fields with a shared legend (e.g. radio groups) |
Adding a New Form
- Add a schema to
lib/validations/your-feature.ts - Add a Server Action to
app/actions/your-feature.ts - Create a Client Component form using
useForm+zodResolver - Call the action in
handleSubmitand 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
| Schema | Purpose |
|---|---|
registerSchema | Registration form (name, email, password) |
loginSchema | Sign-in form (email, password) |
forgotPasswordSchema | Forgot password form (email) |
verifyEmailOtpSchema | Verify-email form (6-digit OTP code) |
resetPasswordSchema | Reset password (password, confirmPassword with match refinement) |
changePasswordSchema | Change password (currentPassword, newPassword, confirmPassword with match refinement) |
user.ts
| Schema | Purpose |
|---|---|
updateProfileSchema | Profile update (name) |
changeEmailSchema | Email change (currentEmail, newEmail, callbackURL) |
createUserSchema | Admin user creation (name, email, role, sendEmail) |
avatarFilenameSchema | Avatar filename validation |
avatarExtensionSchema | Avatar file extension validation |
settings.ts
| Schema | Purpose |
|---|---|
settingsSchema | User notification settings (notifications.productUpdates, notifications.marketingEmails) |
files.ts
| Schema | Purpose |
|---|---|
folderSchema | Folder creation (name) |
fileUploadSchema | File upload validation (filename, contentType) |
contact.ts
| Schema | Purpose |
|---|---|
contactSchema | Contact form (name, email, message) |
UI Components
Pre-installed shadcn/ui components in Next Starter, including theming with CSS variables, Tailwind CSS v4, the cn() utility, and how to add new components with the shadcn CLI.
Environment Variables
How Next Starter validates environment variables with Zod at startup, plus a complete reference for every required and optional variable: DATABASE_URL, BETTER_AUTH_SECRET, Stripe, SMTP2Go, Cloudflare R2, and more.