Forms
How Next Starter builds forms: Zod 4 schemas for validation, typed Server Actions, React Hook Form with Field primitives, and Turnstile on the contact form.
How forms work
Every form is built from three parts. Each part has one job:
- Schema: a Zod 4 schema in
lib/validations/. It also exports the matching TypeScript type. - Server Action: a
"use server"function inapp/actions/that validates the input, runs the work (send an email, write to the database), and returns a result object. It never throws to the client. - Client form: a
"use client"component built with React Hook Form (useForm+zodResolver) and the shadcnFieldprimitives fromcomponents/ui/field.
The schema is the single source of truth. The same type flows into the action and the form, so they can never drift apart. Zod runs twice: in the browser for instant feedback, and again inside the action because you can never trust data sent from a client.
The contact form is the reference to copy:
| Part | File |
|---|---|
| Schema | lib/validations/contact.ts |
| Server Action | app/actions/contact.ts |
| Client form | app/(site)/contact/contact-form.tsx |
The Server Action contract
The action returns a result object, { success, error? }, instead of throwing. This is on purpose. A thrown error in a Server Action reaches the client as a generic, unhelpful message, and in production the real reason is hidden. By catching everything and returning a typed Promise<ContactFormResponse>, the form always gets a clear value to render: a success state or a readable error string. The return type is shared, so the form knows the exact shape at compile time.
Errors are logged with after() from next/server, so writing the log never delays the response to the user.
// app/actions/contact.ts
export async function submitContactForm(
formData: FormData,
): Promise<ContactFormResponse> {
try {
const turnstileToken = formData.get("turnstile_token") as string | null;
if (!turnstileToken || !(await validateTurnstile(turnstileToken))) {
return { success: false, error: "Captcha verification failed" };
}
const { name, email, message } = contactSchema.parse({ /* from formData */ });
// ...send the email...
return { success: true };
} catch (error) {
after(() => logger.error({ err: error }, "Contact form submission failed"));
return { success: false, error: "Failed to send message" };
}
}This project uses Zod 4. Write z.email(), not the Zod 3 z.string().email().
Cloudflare Turnstile
Only the contact form has a bot check. It's Cloudflare Turnstile via @marsidev/react-turnstile, a check that runs without puzzles. There's no reCAPTCHA and no React context provider. The auth forms (sign-in, register, forgot-password) have no captcha.
The widget lives in one wrapper, CaptchaWidget in components/captcha-widget.tsx. It renders the <Turnstile> widget invisibly with appearance: "interaction-only", so it solves silently for low-risk visitors and only shows a challenge when one is needed. A companion hook, useTurnstile(), owns the token. It hands the form the current token, a setToken to pass into the widget's onToken callback, and a reset() to clear a used token after each attempt, since Turnstile tokens are single-use.
Here's the path the token takes on the contact form:
- On submit, the form stops with the shared
CAPTCHA_PROMPTmessage if the token is still empty. - Otherwise it adds the token to
FormDataasturnstile_tokenand calls the action. - The action passes it to
validateTurnstile()inapp/actions/turnstile.ts, which POSTs to Cloudflare's siteverify endpoint and fails closed on any error.
Both keys, NEXT_PUBLIC_TURNSTILE_SITE_KEY (client) and TURNSTILE_SECRET_KEY (server), are still required at boot even though only the contact form reads them. See Turnstile for setup and keys.
The client form
Every form builds its fields the same way. Each field is a Controller. Its render prop puts a shadcn input inside a Field wrapper and shows fieldState.error when fieldState.invalid.
What differs is where the submit goes. The contact form calls the Server Action and reads response.success. The auth forms call the Better Auth client instead (for example signUp.email) and read the error it returns. See Authentication for that side.
The contact form is also the only one with the captcha. It renders one CaptchaWidget, reads the token from useTurnstile(), and calls reset() on any error so the next try gets a fresh token.
The components/ui/field.tsx primitives build the field UI:
| Primitive | Purpose |
|---|---|
Field | Wraps label, input, and error; toggles error styling with data-invalid |
FieldGroup | Stacks fields with even spacing |
FieldLabel | Accessible label tied to the input |
FieldError | Shows the Zod message (errors={[fieldState.error]}) |
FieldSet / FieldLegend | Group related fields under a legend (radio or checkbox groups) |
Inputs also set aria-invalid, so the visual error state and the screen-reader state always match.
What you can change
Add a field. Edit in this order, or the types fall out of sync:
lib/validations/.formData in the Server Action. The inferred type updates on its own.Controller block for it in the form, plus a defaultValues entry.Change validation. Edit the schema only. The client resolver and the server parse() both pick it up. For custom or cross-field rules, use .refine() (see the password-match checks in lib/validations/auth.ts).
Add a new form. Create lib/validations/<feature>.ts, an action in app/actions/<feature>.ts that returns { success, error? }, and a client component using useForm + zodResolver. Copy the contact form to start. Keeping schemas in lib/validations/ lets the same type feed the form, the action, and any API route.
Schema reference
All form schemas live in lib/validations/.
| File | Schema | Used by |
|---|---|---|
contact.ts | contactSchema | Contact form (name, email, message) |
auth.ts | registerSchema | Registration (name, email, password) |
auth.ts | loginSchema | Sign-in (email, password) |
auth.ts | forgotPasswordSchema | Forgot password (email) |
auth.ts | verifyEmailOtpSchema | Verify email (6-digit OTP) |
auth.ts | resetPasswordSchema | Reset password (with match refinement) |
auth.ts | changePasswordSchema | Change password (with match refinement) |
user.ts | updateProfileSchema | Profile update (name) |
user.ts | changeEmailSchema | Email change (currentEmail, newEmail, callbackURL) |
user.ts | createUserSchema | Admin user creation (name, email, role, sendEmail) |
user.ts | avatarFilenameSchema | Avatar filename safety check |
user.ts | avatarUploadSchema | Avatar extension and content type |
settings.ts | settingsSchema | Notification settings (productUpdates, marketingEmails) |
files.ts | folderSchema | Folder creation (name) |
files.ts | fileUploadSchema | File upload (filename, contentType) |
UI Components
The 30 shadcn/ui components pre-installed in Next Starter: new-york style on Radix primitives, styled with Tailwind CSS v4 and themed with CSS variables.
Environment Variables
How Next Starter validates environment variables with Zod at startup, plus a grouped config reference for every required and optional variable.