Why a booking form is harder than a contact form
A contact form has three fields and one outcome. A booking request has a service, a date, a time preference, a duration, contact details, possibly a staff choice, possibly notes, and it has to validate relationships between fields: this service is only available on these days; this date must be at least 48 hours out; this time must be within opening hours. The validation needs to run instantly as the user types, and again authoritatively on the server, with the same rules, or the two will disagree.
That is the case for a shared schema. Zod defines the rules once; React Hook Form runs them in the browser; the server action runs them again before doing anything.
The schema
// lib/booking/schema.ts
import { z } from 'zod'
export const SERVICES = ['consultation', 'treatment-60', 'treatment-90'] as const
export const bookingSchema = z
.object({
service: z.enum(SERVICES, { message: 'Choose a service.' }),
date: z.string().refine((d) => !Number.isNaN(Date.parse(d)), 'Choose a date.'),
time: z.enum(['morning', 'afternoon', 'evening'], { message: 'Choose a time of day.' }),
name: z.string().trim().min(2, 'Enter your name.').max(100),
email: z.string().trim().email('Enter a valid email.'),
phone: z.string().trim().min(7, 'Enter a phone number.').max(20),
notes: z.string().trim().max(500).optional().or(z.literal('')),
website: z.string().max(0), // honeypot
})
.superRefine((v, ctx) => {
const d = new Date(v.date)
const min = new Date(); min.setDate(min.getDate() + 2)
if (d < min) {
ctx.addIssue({ code: 'custom', path: ['date'], message: 'Bookings need at least two days\' notice.' })
}
if ([0, 6].includes(d.getDay())) {
ctx.addIssue({ code: 'custom', path: ['date'], message: 'We are closed at weekends.' })
}
if (v.service === 'treatment-90' && v.time === 'evening') {
ctx.addIssue({ code: 'custom', path: ['time'], message: '90-minute treatments are not available in the evening.' })
}
})
export type BookingInput = z.input<typeof bookingSchema>
export type BookingData = z.output<typeof bookingSchema>One file, imported by both sides. Cross-field rules live in superRefine with a path so the error lands on the right field.
The server action
// app/book/actions.ts
'use server'
import { bookingSchema } from '@/lib/booking/schema'
import { createBookingRequest } from '@/lib/booking/store'
import { notifyStudio } from '@/lib/mail'
export type ActionResult =
| { ok: true; reference: string }
| { ok: false; fieldErrors?: Record<string, string>; message?: string }
export async function submitBooking(_prev: ActionResult | null, formData: FormData): Promise<ActionResult> {
const parsed = bookingSchema.safeParse(Object.fromEntries(formData))
if (!parsed.success) {
const fieldErrors: Record<string, string> = {}
for (const issue of parsed.error.issues) {
const key = String(issue.path[0] ?? '')
if (key === 'website') return { ok: true, reference: 'ok' } // bot: fake success
if (key && !fieldErrors[key]) fieldErrors[key] = issue.message
}
return { ok: false, fieldErrors, message: 'Please check the highlighted fields.' }
}
try {
const reference = await createBookingRequest(parsed.data)
await notifyStudio(parsed.data, reference)
return { ok: true, reference }
} catch (err) {
console.error('booking failed', err)
return { ok: false, message: 'Sorry, that did not go through. Call us and we will book you in.' }
}
}The action accepts FormData, so a plain form post without JavaScript works. It re-validates with the same schema. It writes the request somewhere durable before emailing, so a mail failure does not lose the booking. It returns field-level errors in a shape the client can render.
The client component
React Hook Form gives instant validation and a good keyboard experience; useActionState gives the server's response; the form still submits as a real form.
// components/BookingForm.tsx
'use client'
import { useActionState, useEffect, useRef } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { bookingSchema, SERVICES, type BookingInput } from '@/lib/booking/schema'
import { submitBooking, type ActionResult } from '@/app/book/actions'
export function BookingForm() {
const [result, action, pending] = useActionState<ActionResult | null, FormData>(submitBooking, null)
const formRef = useRef<HTMLFormElement>(null)
const { register, handleSubmit, setError, formState: { errors } } = useForm<BookingInput>({
resolver: zodResolver(bookingSchema),
mode: 'onBlur',
})
// Surface server-side field errors in RHF so they render in the same place
useEffect(() => {
if (result && !result.ok && result.fieldErrors) {
for (const [field, message] of Object.entries(result.fieldErrors)) {
setError(field as keyof BookingInput, { message })
}
}
}, [result, setError])
if (result?.ok) {
return (
<p role="status">
Thanks. Your request is in (reference {result.reference}). We will confirm the exact time by text within one working day.
</p>
)
}
return (
<form
ref={formRef}
action={action}
onSubmit={handleSubmit(() => formRef.current?.requestSubmit())}
noValidate
>
<label>Service
<select {...register('service')} aria-invalid={!!errors.service}>
<option value="">Choose…</option>
{SERVICES.map((s) => <option key={s} value={s}>{s}</option>)}
</select>
{errors.service && <span role="alert">{errors.service.message}</span>}
</label>
<label>Preferred date
<input type="date" {...register('date')} aria-invalid={!!errors.date} />
{errors.date && <span role="alert">{errors.date.message}</span>}
</label>
<fieldset>
<legend>Time of day</legend>
{(['morning', 'afternoon', 'evening'] as const).map((t) => (
<label key={t}><input type="radio" value={t} {...register('time')} /> {t}</label>
))}
{errors.time && <span role="alert">{errors.time.message}</span>}
</fieldset>
<label>Name <input {...register('name')} autoComplete="name" aria-invalid={!!errors.name} />
{errors.name && <span role="alert">{errors.name.message}</span>}</label>
<label>Email <input type="email" {...register('email')} autoComplete="email" aria-invalid={!!errors.email} />
{errors.email && <span role="alert">{errors.email.message}</span>}</label>
<label>Mobile <input type="tel" {...register('phone')} autoComplete="tel" aria-invalid={!!errors.phone} />
{errors.phone && <span role="alert">{errors.phone.message}</span>}</label>
<label>Anything we should know? <textarea {...register('notes')} rows={3} /></label>
<div aria-hidden="true" style={{ position: 'absolute', left: '-9999px' }}>
<label>Website <input {...register('website')} tabIndex={-1} autoComplete="off" /></label>
</div>
{result && !result.ok && result.message && <p role="alert">{result.message}</p>}
<button type="submit" disabled={pending}>{pending ? 'Sending…' : 'Request booking'}</button>
</form>
)
}The onSubmit dance: React Hook Form validates client-side; if valid, requestSubmit() triggers the form's native submission, which invokes the server action with the FormData. If JavaScript has not loaded, the form posts directly to the action and the server's validation is the only validation, which is fine.
The pieces around it
- The store. A database row, a Google Sheet via API, or a direct call into the booking tool's API if it accepts requests. Return a reference the client can quote.
- The notification. Email to the studio through a transactional service, and an SMS to the client acknowledging the request if you want the confirmation loop to feel real.
- Tracking. Fire the
booking_requestevent from auseEffectonresult.ok. - Availability. This form takes a request, not a confirmed slot. For real-time availability you either embed the booking tool's widget or call its availability API server-side to populate the date and time options. Start with the request form; upgrade when the volume justifies it.
Where this sits
This is the shape of every booking or quote form we build into Next.js sites: one schema, instant and authoritative validation, a form that works before hydration, a durable write before the email. It is more code than a widget embed and it is entirely yours, which matters when the booking tool changes its embed for the third time.