Next Starter Logo
Components

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:

  1. Schema: a Zod 4 schema in lib/validations/. It also exports the matching TypeScript type.
  2. Server Action: a "use server" function in app/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.
  3. Client form: a "use client" component built with React Hook Form (useForm + zodResolver) and the shadcn Field primitives from components/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:

PartFile
Schemalib/validations/contact.ts
Server Actionapp/actions/contact.ts
Client formapp/(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:

  1. On submit, the form stops with the shared CAPTCHA_PROMPT message if the token is still empty.
  2. Otherwise it adds the token to FormData as turnstile_token and calls the action.
  3. The action passes it to validateTurnstile() in app/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:

PrimitivePurpose
FieldWraps label, input, and error; toggles error styling with data-invalid
FieldGroupStacks fields with even spacing
FieldLabelAccessible label tied to the input
FieldErrorShows the Zod message (errors={[fieldState.error]})
FieldSet / FieldLegendGroup 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:

Add the field to the Zod schema in lib/validations/.
Read it from formData in the Server Action. The inferred type updates on its own.
Add a 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/.

FileSchemaUsed by
contact.tscontactSchemaContact form (name, email, message)
auth.tsregisterSchemaRegistration (name, email, password)
auth.tsloginSchemaSign-in (email, password)
auth.tsforgotPasswordSchemaForgot password (email)
auth.tsverifyEmailOtpSchemaVerify email (6-digit OTP)
auth.tsresetPasswordSchemaReset password (with match refinement)
auth.tschangePasswordSchemaChange password (with match refinement)
user.tsupdateProfileSchemaProfile update (name)
user.tschangeEmailSchemaEmail change (currentEmail, newEmail, callbackURL)
user.tscreateUserSchemaAdmin user creation (name, email, role, sendEmail)
user.tsavatarFilenameSchemaAvatar filename safety check
user.tsavatarUploadSchemaAvatar extension and content type
settings.tssettingsSchemaNotification settings (productUpdates, marketingEmails)
files.tsfolderSchemaFolder creation (name)
files.tsfileUploadSchemaFile upload (filename, contentType)

On this page