The Requirement
Clients never ask for "a basic contact form." They ask for a form that:
- validates properly
- does not lose submissions
- avoids spam
- sends to the right inbox
- feels fast on mobile
- does not expose secrets in the browser
In 2026, the cleanest setup for a Next.js portfolio or services site is still React Hook Form on the client, Zod for validation, a Server Action for the mutation, and Resend for delivery.
That gives you a small client bundle, typed validation on both sides, and no API route unless you genuinely need one.
The Validation Contract
Keep the schema in one place:
// lib/contact-schema.ts
import { z } from 'zod'
export const contactSchema = z.object({
name: z.string().min(2).max(80),
email: z.string().email(),
company: z.string().max(120).optional(),
budget: z.string().max(60).optional(),
message: z.string().min(20).max(5000),
website: z.string().max(0).optional(), // honeypot
})
export type ContactInput = z.infer<typeof contactSchema>
The hidden website field is the cheapest spam trap you can add. Humans leave it empty. Bots often fill it.
The Server Action
The action should do four jobs:
- Parse and validate the payload
- Reject spammy submissions early
- Send the email
- Return a predictable success or error shape
'use server'
import { Resend } from 'resend'
import { contactSchema } from '@/lib/contact-schema'
const resend = new Resend(process.env.RESEND_API_KEY)
export async function sendContact(payload: unknown) {
const parsed = contactSchema.safeParse(payload)
if (!parsed.success) {
return { ok: false, error: 'Please check the form fields and try again.' }
}
if (parsed.data.website) {
return { ok: true }
}
await resend.emails.send({
from: 'Website Contact <contact@qasimcode.com>',
to: ['hello@qasimcode.com'],
replyTo: parsed.data.email,
subject: `New enquiry from ${parsed.data.name}`,
text: [
`Name: ${parsed.data.name}`,
`Email: ${parsed.data.email}`,
`Company: ${parsed.data.company || 'N/A'}`,
`Budget: ${parsed.data.budget || 'N/A'}`,
'',
parsed.data.message,
].join('\\n'),
})
return { ok: true }
}
Why a Server Action instead of a public API route? Two reasons:
- the API key never leaves the server boundary
- the form logic stays colocated with the UI flow instead of growing a second transport layer
If you recently upgraded Next.js and form submissions started failing with mysterious 400s, that is a separate deployment issue. I covered that in my Server Actions invalid error fix.
The Client Form
I still prefer React Hook Form for the client because it handles touched state, inline errors, and submit lifecycles without forcing every keystroke through React state.
'use client'
import { useTransition } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { contactSchema, type ContactInput } from '@/lib/contact-schema'
import { sendContact } from './actions'
export function ContactForm() {
const [isPending, startTransition] = useTransition()
const form = useForm<ContactInput>({
resolver: zodResolver(contactSchema),
defaultValues: {
name: '',
email: '',
company: '',
budget: '',
message: '',
website: '',
},
})
const onSubmit = form.handleSubmit((values) => {
startTransition(async () => {
const result = await sendContact(values)
if (result.ok) form.reset()
else form.setError('root', { message: result.error })
})
})
return <form onSubmit={onSubmit}>{/* fields */}</form>
}
That is enough for most sites. I only move to a more elaborate queue or CRM handoff when the volume justifies it.
Production Concerns People Skip
1. Domain verification in Resend. Do not ship from the default sandbox sender and assume it is done. Verify the sending domain, align SPF and DKIM, and use a real branded sender address.
2. Rate limiting. The honeypot catches lazy bots, not determined ones. Put Cloudflare Turnstile or a lightweight rate limit in front if the site gets hit regularly.
3. Reply-to handling. Use your own verified sender in from, then set replyTo to the submitter's email. Sending "from the customer's address" is how deliverability gets ugly fast.
4. Logging without leaking PII. Log failures, but do not dump full form bodies into third-party log tools unless you need that data and have permission to store it.
5. Success UX. Users need a clear success state, not a disabled button that silently re-enables. I prefer a proper confirmation message and a reset form state.
When I Add More Protection
On higher-volume sites, I usually layer:
- honeypot
- server-side schema validation
- Cloudflare Turnstile
- basic IP or session rate limit
- content heuristics for obvious spam phrases
You do not need all of that on day one. But you should at least design the action so these checks can be added without rewriting the whole flow.
The Practical Stack
For a services site, the boring stack is the right one:
- React Hook Form for UX
- Zod for shared validation
- Server Action for submission
- Resend for mail delivery
- one spam trap from day one
That combination is small, maintainable, and easy to reason about when something fails.
Want This Wired Up Properly?
I build contact flows that do the unglamorous work properly: validation, delivery, spam handling, analytics, and clean handoff into the inbox. If your current form mostly works but you do not trust it, get in touch.
