What a good form is
A contact form on a business site has one job: get the enquiry to a human without losing it. Everything else, validation, pending states, error messages, spam filtering, exists to make that reliable. The App Router's server actions plus React's useActionState give you the pieces to do it with very little client code and a form that still works if JavaScript has not loaded.
Here is the complete pattern we ship.
The server action
The action runs on the server, receives the form data, validates it, sends it somewhere, and returns a state object the form can render.
// app/contact/actions.ts
'use server'
import { z } from 'zod'
import { sendEnquiry } from '@/lib/mail'
const Schema = z.object({
name: z.string().trim().min(2).max(100),
email: z.string().trim().email(),
message: z.string().trim().min(10).max(5000),
website: z.string().max(0), // honeypot: must be empty
})
export type FormState = {
status: 'idle' | 'success' | 'error'
message?: string
errors?: Partial<Record<'name' | 'email' | 'message', string>>
}
export async function submitEnquiry(
_prev: FormState,
formData: FormData,
): Promise<FormState> {
const parsed = Schema.safeParse(Object.fromEntries(formData))
if (!parsed.success) {
const errors: FormState['errors'] = {}
for (const issue of parsed.error.issues) {
const key = issue.path[0]
if (key === 'website') return { status: 'success' } // silently drop bots
if (key === 'name' || key === 'email' || key === 'message') {
errors[key] = 'Please check this field.'
}
}
return { status: 'error', message: 'Please fix the highlighted fields.', errors }
}
try {
await sendEnquiry(parsed.data)
return { status: 'success' }
} catch (err) {
console.error('enquiry failed', err)
return {
status: 'error',
message: 'Sorry, that did not send. Email us directly and we will reply today.',
}
}
}Three things to notice. Validation is server-side and authoritative; client validation is a convenience layered on top. The honeypot field, website, is hidden from humans and must be empty; a filled one returns a fake success so the bot learns nothing. And a send failure returns a useful message with a fallback route, because a form that says "error" and nothing else loses the enquiry.
The form component
// components/ContactForm.tsx
'use client'
import { useActionState } from 'react'
import { submitEnquiry, type FormState } from '@/app/contact/actions'
const initial: FormState = { status: 'idle' }
export function ContactForm() {
const [state, action, pending] = useActionState(submitEnquiry, initial)
if (state.status === 'success') {
return <p role="status">Thanks. We reply within one working day.</p>
}
return (
<form action={action} noValidate>
<label>
Name
<input name="name" autoComplete="name" required aria-invalid={!!state.errors?.name} />
{state.errors?.name && <span role="alert">{state.errors.name}</span>}
</label>
<label>
Email
<input name="email" type="email" autoComplete="email" required aria-invalid={!!state.errors?.email} />
{state.errors?.email && <span role="alert">{state.errors.email}</span>}
</label>
<label>
Message
<textarea name="message" rows={6} required aria-invalid={!!state.errors?.message} />
{state.errors?.message && <span role="alert">{state.errors.message}</span>}
</label>
<div aria-hidden="true" style={{ position: 'absolute', left: '-9999px' }}>
<label>Website <input name="website" tabIndex={-1} autoComplete="off" /></label>
</div>
{state.status === 'error' && state.message && <p role="alert">{state.message}</p>}
<button type="submit" disabled={pending}>
{pending ? 'Sending…' : 'Send enquiry'}
</button>
</form>
)
}useActionState returns the latest state from the action, the action to pass to the form, and a pending flag. That is the entire client-side state management. No useState for fields, no fetch call, no loading flag to keep in sync.
Progressive enhancement
Because the form's action is a server action, it submits as a normal form post before hydration. A visitor on a slow connection who clicks submit before JavaScript loads still sends the enquiry; React takes over when it arrives. Do not break this by preventing default in an onSubmit.
Preserving input on error
By default, a validation error returned from the action re-renders the form and the browser keeps the field values, because the inputs are uncontrolled. If you make them controlled, you take responsibility for preserving them. Leave them uncontrolled.
Spam, properly
The honeypot catches simple bots. For a form that attracts more, add a time check (submissions faster than two seconds after render are bots), rate-limit the action by IP at the edge, and only then consider a CAPTCHA, which costs real conversions. Most business sites never need the CAPTCHA.
Delivery
The action should send through a transactional email service, not the host's mail function, and should also write the enquiry somewhere durable, a database row or a CRM, so a mail failure does not lose it. Return success only after at least one of those has succeeded.
Tracking the conversion
On success, fire the analytics event from the client component, since the action has no access to the browser. A useEffect keyed on state.status === 'success' that calls your tracking helper once is enough. This is the event you will build ad campaigns and reporting on, so name it deliberately.
Testing it
Submit with JavaScript disabled. Submit with an invalid email. Submit with the honeypot filled. Break the mail credentials and submit. Each should behave as designed, and the last one should produce an error message with a fallback route, not a blank form.
This is the form on our own contact page, and it is the one we build into Next.js client sites. The pattern is small on purpose: a form that loses enquiries is worse than no form, and the fewer moving parts, the fewer ways to lose one.