Skip to content
~/rifqi_abrory

Pages

Home
About
Stack
Experience
Projects
Writing
Contact
All projects

Projects

Avrist PINTAR — Agent Portal
Avrist e-Application
Avrist Academy — LMS CMS
Avrist e-Payment
PRUHub
SIYAP

Articles

Angular signals for everyday admin pages
Typed reactive forms in Angular, without the any
Consistent API errors in ASP.NET Core with ProblemDetails
Passing CancellationToken all the way down in ASP.NET Core
Graceful shutdown for Go HTTP services
Idempotency keys: making payment APIs safe to retry
A spam-resistant contact form with Next.js Server Actions
Offline-friendly forms in React Native: drafts and a submit queue
Request validation and error handling in Spring Boot 3
Keyset pagination in SQL Server: when OFFSET gets slow

Actions

Switch to dark mode
Read in Bahasa Indonesia
Copy email address
All articles

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:

  1. A honeypot field. An input named website, hidden from people with CSS and aria-hidden, and skipped by keyboard with tabIndex={-1}. People leave it empty; naive bots fill every field.
  2. A timing check. The form records when it was rendered in a hidden startedAt field. 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 useActionState replaces 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.