A spam-resistant contact form with Next.js Server Actions
How the contact form on this site works: a Server Action, Zod validation, useActionState, a honeypot, a timing check, and Resend.
3 min read
On this page
Requirements
The contact form on this site had a short list of requirements:
- No separate API route and no client-side fetch code.
- The same validation rules on the server, with friendly messages per field.
- Values kept when something fails, so nobody has to retype a long message.
- Basic spam protection without a CAPTCHA.
- Email delivery through Resend, with the API key only on the server.
Next.js Server Actions combined with React 19's useActionState cover all of it.
One schema for the rules
// lib/validations.ts
import { z } from "zod";
export const contactSchema = z.object({
name: z.string().trim().min(2).max(100),
email: z.string().trim().max(200).pipe(z.email()),
message: z.string().trim().min(10).max(5000),
});
export type ContactInput = z.infer<typeof contactSchema>;
export type ContactField = keyof ContactInput;The Server Action
The action receives the previous state and the submitted FormData, and returns the next state.
The state is a small discriminated union, so the form always knows exactly what happened:
"use server";
export type ContactState =
| { status: "idle" }
| { status: "success" }
| {
status: "error";
error: "validation" | "rateLimited" | "server";
fields: ContactField[];
values: Partial<ContactInput>;
};
export async function sendContact(_prev: ContactState, formData: FormData): Promise<ContactState> {
const values = {
name: readString(formData, "name"),
email: readString(formData, "email"),
message: readString(formData, "message"),
};
const parsed = contactSchema.safeParse(values);
if (!parsed.success) {
const fields = [...new Set(parsed.error.issues.map((issue) => issue.path[0] as ContactField))];
return { status: "error", error: "validation", fields, values };
}
const { name, email, message } = parsed.data;
const { error } = await new Resend(process.env.RESEND_API_KEY).emails.send({
from: process.env.CONTACT_FROM_EMAIL!,
to: process.env.CONTACT_TO_EMAIL!,
replyTo: email,
subject: `Portfolio contact from ${name}`,
text: `Name: ${name}\nEmail: ${email}\n\n${message}`,
});
return error ? { status: "error", error: "server", fields: [], values } : { status: "success" };
}Returning values on error is what keeps the user's input: the form uses them as default values on
the next render. Setting replyTo to the visitor's address means replying from the inbox goes
straight to them.
The form
"use client";
import { useActionState } from "react";
const initialState: ContactState = { status: "idle" };
export function ContactForm() {
const [state, formAction, pending] = useActionState(sendContact, initialState);
if (state.status === "success") return <p role="status">Thanks! I will get back to you soon.</p>;
const error = state.status === "error" ? state : null;
const invalid = (field: ContactField) => error?.fields.includes(field) ?? false;
return (
<form action={formAction} noValidate>
<label htmlFor="contact-email">Email</label>
<input
id="contact-email"
name="email"
type="email"
defaultValue={error?.values.email}
aria-invalid={invalid("email") || undefined}
aria-describedby={invalid("email") ? "contact-email-error" : undefined}
/>
{invalid("email") ? <p id="contact-email-error">Enter a valid email address.</p> : null}
{/* name and message follow the same pattern */}
<button type="submit" disabled={pending}>
{pending ? "Sending…" : "Send message"}
</button>
</form>
);
}pending comes straight from useActionState, so there is no manual loading state. Because the
form uses a real action, it still submits when JavaScript has not loaded yet.
Spam protection without a CAPTCHA
Most contact-form spam comes from simple bots. Two cheap checks stop a large share of it:
- A honeypot field. An input named
website, hidden from people with CSS andaria-hidden, and skipped by keyboard withtabIndex={-1}. People leave it empty; naive bots fill every field. - A timing check. The form records when it was rendered in a hidden
startedAtfield. A submission that arrives within three seconds was not typed by a human.
const startedAt = Number(readString(formData, "startedAt"));
const tooFast = startedAt > 0 && Date.now() - startedAt < 3_000;
if (readString(formData, "website") || tooFast) {
// Pretend it worked, so the bot has no signal to adapt to.
return { status: "success" };
}startedAt is set in an effect on the client. When JavaScript has not run, the field is empty, and
that alone is not treated as a bot.
Rate limiting
On top of that, a small in-memory sliding window allows five messages per IP per hour. On serverless platforms the memory is per instance, so this only blunts bursts; for stricter limits, a shared store such as Redis is the next step. For a personal site, the honeypot and timing check carry most of the weight.
Summary
- A Server Action plus
useActionStatereplaces an API route and client fetch code. - One Zod schema defines the rules; the action returns field-level errors and the original values.
- A honeypot and a timing check stop simple bots without a CAPTCHA.
- Secrets stay on the server, and the form works before JavaScript loads.