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, 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()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 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'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
useFormis initialized withresolver: zodResolver(contactSchema), which runs client-side validation before the action is called.FormFieldwraps each input and connects it to the form control via therenderprop.FormMessagerenders the Zod validation error for that field automatically.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.
Form Primitives
The components/ui/form.tsx component is a thin wrapper around React Hook Form's context API. The building blocks are:
| Component | Purpose |
|---|---|
Form | Provides form context; wrap the <form> element with this |
FormField | Connects a named field to the form state |
FormItem | Layout wrapper for label + input + message |
FormLabel | Accessible label, turns red on error |
FormControl | Slot for the input element |
FormDescription | Optional hint text below the input |
FormMessage | Displays the validation error for the field |
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) |
emailVerificationSchema | Email verification (email) |
tokenVerificationSchema | Token verification (token, email) |
emailChangeVerificationSchema | Email change verification (token, email) |
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, password, role) |
updateUserByAdminSchema | Admin user edit (name, email, role, banned, banReason, banExpires) |
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.