A contact form with no form service: Server Actions and Gmail
How this site's contact form works: one Server Action, server-side validation, a honeypot, a rate limit and reply-to set to the visitor.
A portfolio needs a way to be contacted, and mailto: fails for anyone without a mail client set up. Form services solve that, but they can add a dependency, a usage limit and someone else's branding. This site uses a single Next.js Server Action that sends the message to my inbox. Here is how it works and what I added to keep it from becoming a spam relay.
One function, no API route
The form posts to a Server Action, a function marked "use server" that runs only on the server:
"use server";
export async function sendContact(_prev: ContactState, formData: FormData) {
// validate, rate limit, send
}
The client component calls it with useActionState, which hands back the result of the last submission. That gives me pending state, field errors and the success message without writing a fetch call, a route handler or any client-side request code.
Treat it like a public endpoint
The Next.js docs are blunt about this: Server Functions are reachable by direct POST requests, not just through your UI. So nothing in the action trusts the form:
- Validation happens on the server. The browser's
requiredattributes are a convenience. The action trims every field, caps its length and checks the email format itself, then returns per-field errors. - Errors keep what the visitor typed. The action returns the submitted values, so a failed send doesn't wipe a message someone just wrote.
- The name is cleaned before it reaches a header. It ends up in the subject and the reply-to name, so line breaks, quotes and angle brackets are stripped first, and the address goes through Nodemailer's structured address handling instead of being glued into a string.
Cheap spam protection
Two small measures cover most of the noise:
- A honeypot field. It's an extra input that is hidden from people and from assistive technology. Bots tend to fill every field, and a filled honeypot gets a fake "sent" response so the bot doesn't retry.
- A rate limit. Three messages per IP address per ten minutes. It lives in server memory, so it resets on restart and isn't shared between serverless instances. That's a limitation, not a guarantee. If spam gets through, the next step is a CAPTCHA such as Cloudflare Turnstile.
Sending the mail
The action uses nodemailer with Gmail and an App Password. It isn't the account password. You create it under Google Account → Security → 2-Step Verification → App passwords, and it can be revoked on its own. The address and password live in environment variables (CONTACT_EMAIL_USER, CONTACT_EMAIL_PASS) and never reach the client.
One detail makes the inbox side pleasant: the visitor's address is set as the message's replyTo, so answering the notification goes straight to them.
Failing politely
If the environment variables are missing or Gmail rejects the message, the form doesn't show a stack trace. It says the form isn't available and points to the email address, and the real error goes to the server log.
What I'd change at scale
This is the right size for a portfolio. If traffic grew, I'd move to a transactional email API, store submissions in a database before sending so nothing is lost, and put a shared rate limiter (Redis or the host's edge) in front of the action.
The takeaway is that a form needs validation, abuse protection and a graceful failure path before it needs a service. A Server Action puts the whole flow in one server-side function, and I wrote the validation, the abuse protection and the failure handling inside it in under a hundred lines.