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 starts from the same two parts:
- Schema: a Zod 4 schema in
lib/validations/. Some files also export the matching TypeScript type; the others are inferred where they're used withz.infer<typeof …>. - Client form: a
"use client"component built with React Hook Form (useForm+zodResolver) and the shadcnFieldprimitives fromcomponents/ui/field.
Where the submit goes is what varies. Credentials and identity (sign-in, register, password, email change, profile name) go through the Better Auth client, which does its own checking on the server. Everything else, avatars and admin user creation included, posts to a "use server" function in app/actions/ that does the work and returns a { success, error? } object rather than throwing.
The schema is the single source of truth, so the form and the action validate against one definition. Most actions also re-run the schema on what arrives, with parse() or safeParse(), which means validation happens twice: in the browser for instant feedback, and again on the server, where data sent from a client can never be trusted.
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. Catching everything and returning a typed Promise<ApiResponse> (the shared shape in types/api.ts) means the form always has something to render: a success state or a readable error string.
The action logs errors 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<ApiResponse> {
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(
{ event: "contact_form_failure", err: error },
"Contact form submission failed",
);
});
return { success: false, error: "Failed to send message" };
}
}This project uses Zod 4. Email fields are z.email(), a top-level format schema, and the password-match .refine() calls in lib/validations/auth.ts pass their message as error. Follow the schemas in lib/validations/ when you add your own.
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 keeps the <Turnstile> widget out of sight with appearance: "interaction-only" and an sr-only wrapper, so it solves silently for low-risk visitors and only appears when a challenge is needed. A companion hook, useTurnstile(), owns both the token and the widget ref. It hands the form four things: a ref for the widget's instanceRef prop, the current token, a setToken for the widget's onToken callback, and a reset() that clears the token and tells the widget to fetch a new one. Turnstile tokens are single-use, so that last one matters after every failed attempt.
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()inlib/server/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 editable text field is built the same way. Each one is a Controller whose render prop puts a shadcn input inside a Field wrapper and shows fieldState.error when fieldState.invalid. The settings page's switches and the read-only current-email box on the change-email form skip the error handling, since they can't fail validation.
Handling the response is where the forms part ways. The contact form reads response.success off the action. The Better Auth calls hand back different shapes depending on the method, so each of those forms handles its own. Some read the returned error, and sign-in passes an onError callback. 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() after a rejected submit so the next try gets a fresh token.
components/ui/field.tsx exports ten primitives. These five are the ones the starter's forms actually use:
| Primitive | Purpose |
|---|---|
Field | Wraps label, input, and error; turns its text destructive when data-invalid is set |
FieldGroup | Stacks fields with even spacing |
FieldLabel | Accessible label tied to the input |
FieldDescription | Helper text under a label or control |
FieldError | Shows the Zod message (errors={[fieldState.error]}) |
The rest, including FieldSet and FieldLegend for fieldset-and-legend grouping, ship unused. Read the file if you need them.
Neither error attribute is automatic. Each editable text-input Controller sets data-invalid on the Field and aria-invalid on the input itself, both read off the same fieldState.invalid, which is what keeps the visual error state and the screen-reader state in step. The components only carry the matching styles. FieldError renders inside role="alert", so a message that appears mid-form gets announced.
What you can change
Add a field. Edit in this order, or the types fall out of sync:
lib/validations/.formData by name, so add a line for it there. The other actions take an argument; where its type is inferred from the schema (createUser takes CreateUserFormData) the type updates on its own, and where it's a plain parameter (createFolderAction takes a folderName: string) you widen it by hand.Controller block for it in the form, plus a defaultValues entry.Change validation. Edit the schema first. The client resolver picks it up, and so does the server parse() where an action re-checks it. A few inputs carry matching HTML attributes too, like maxLength={32} on the register form's name field, so update those alongside the schema. For custom or cross-field rules, use .refine() (see the password-match checks in lib/validations/auth.ts). The one check that lives outside a schema is on the change-email form, which calls form.setError() when the new address matches the current one.
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.
Where the schemas live
One file per area under lib/validations/, each exporting a <name>Schema. contact.ts is the contact form's. auth.ts covers register, sign-in, forgot / reset / change password, and email OTP. user.ts covers the profile name, email change, and admin user creation, plus two server-side guards on avatar filenames and uploads. It also exports the shared emailSchema and nameSchema that auth.ts reuses. settings.ts is for notification preferences, and files.ts for folder creation and file uploads. settings.ts, files.ts and user.ts also export inferred form types; the auth and contact forms infer theirs locally.
env.ts sits in the same folder and is not a form schema. It checks every environment variable the app needs and throws the moment it's imported if a required one is missing or any value fails its check. A few have defaults or are optional, NODE_ENV and NEXT_PUBLIC_BETTER_AUTH_URL among them.
UI Components
The 30 components pre-installed in Next Starter: shadcn/ui new-york style, Radix primitives where behavior needs them, Tailwind CSS v4 styling and CSS-variable theming.
Environment Variables
How Next Starter validates environment variables with Zod at startup, plus a grouped config reference for every required and optional variable.