Skip to main content
ASoc
Tutorial

A Next.js Contact Form with Server Actions, Zod and Resend

No API route, no client fetch, and it still submits with JavaScript off. Validation, a honeypot, a rate limit that survives serverless, and the from-address trap that kills deliverability.

The ASoc Team11 min read

A contact form in the Next.js App Router needs no API route and no client-side fetch. Write an async function marked "use server", pass it straight to <form action={…}>, validate the payload with Zod on the server, and send the mail from there. Wrap it in useActionState and you get field errors and a pending state for free — while the form still submits with JavaScript disabled.

This post is the full version of that form: validation, a honeypot, rate limiting that survives serverless, the replyTo detail that quietly kills deliverability if you get it wrong, and the progressive-enhancement rule most tutorials break in their first code block.

The smallest thing that works

Start here, then add each layer for a reason rather than by habit:

// src/app/contact/page.tsx
export default function ContactPage() {
  async function submit(formData: FormData) {
    "use server";
    await sendContactEmail({
      email: String(formData.get("email")),
      message: String(formData.get("message")),
    });
  }

  return (
    <form action={submit}>
      <input type="email" name="email" required />
      <textarea name="message" required />
      <button type="submit">Send</button>
    </form>
  );
}

That is a working contact form. It runs on the server, ships no client JavaScript for the submission, and — this is the part worth protecting — it works before React hydrates and with JavaScript turned off, because Next.js renders a real form post that the Server Action handles.

What it does not do is tell the user anything. That is the next layer.

Adding validation and state, without losing the no-JS path

Move the action into its own file, validate with Zod, and return a typed result the form can render:

// src/lib/actions/contact.ts
"use server";

import { z } from "zod";

const ContactSchema = z.object({
  name: z.string().trim().min(1, "Please tell us your name").max(100),
  email: z.email("That email address does not look right"),
  message: z.string().trim().min(20, "A little more detail, please").max(5000),
  // Honeypot: a real person never fills this in.
  company: z.string().max(0).optional(),
});

export type ContactState = {
  ok: boolean;
  errors?: Partial<Record<"name" | "email" | "message" | "form", string>>;
};

export async function submitContact(
  _prev: ContactState,
  formData: FormData,
): Promise<ContactState> {
  const parsed = ContactSchema.safeParse(Object.fromEntries(formData));

  if (!parsed.success) {
    const flat = z.flattenError(parsed.error).fieldErrors;
    // The honeypot is the one failure we do NOT report — a bot that learns
    // which field tripped it just stops filling that field in.
    if (flat.company) return { ok: true };
    return {
      ok: false,
      errors: {
        name: flat.name?.[0],
        email: flat.email?.[0],
        message: flat.message?.[0],
      },
    };
  }

  try {
    await deliver(parsed.data);
  } catch {
    // Never surface the provider's error text. It leaks keys, addresses and
    // internal hostnames into a page anyone can load.
    return { ok: false, errors: { form: "Could not send. Please try again." } };
  }

  return { ok: true };
}

That is Zod 4 syntax — z.email() at the top level and z.flattenError() instead of the old error.flatten(). On Zod 3 they are z.string().email() and parsed.error.flatten(); nothing else changes.

Two decisions in there are worth naming.

The honeypot returns success. A bot that gets an error message learns something. A bot that gets a cheerful "thanks!" learns nothing and moves on, and the mail is silently never sent. Give it the same response a human gets.

The catch block returns a generic string. Provider SDKs put useful things in error messages — the API key prefix, the sending domain, sometimes the recipient. Log the real error server-side, return a sentence.

The client half is a small "use client" component:

"use client";

import { useActionState } from "react";
import { submitContact, type ContactState } from "@/lib/actions/contact";

const initial: ContactState = { ok: false };

export default function ContactForm() {
  const [state, action, pending] = useActionState(submitContact, initial);

  if (state.ok) return <p role="status">Thanks — we will reply within a day.</p>;

  return (
    <form action={action} noValidate>
      <label htmlFor="name">Name</label>
      <input id="name" name="name" autoComplete="name" required
        aria-invalid={!!state.errors?.name}
        aria-describedby={state.errors?.name ? "name-error" : undefined} />
      {state.errors?.name && <p id="name-error">{state.errors.name}</p>}

      {/* Honeypot. Hidden from people and from screen readers, visible to bots. */}
      <div hidden aria-hidden="true">
        <label htmlFor="company">Company</label>
        <input id="company" name="company" tabIndex={-1} autoComplete="off" />
      </div>

      <button type="submit" disabled={pending}>
        {pending ? "Sending…" : "Send message"}
      </button>
      {state.errors?.form && <p role="alert">{state.errors.form}</p>}
    </form>
  );
}

useActionState comes from react, not from react-dom — it was renamed from useFormState in React 19, and a surprising number of live tutorials still show the old import.

Three accessibility details that cost nothing and are almost always missing: aria-invalid on the failed input, aria-describedby pointing at the message, and role="alert" on the form-level error so a screen reader announces it instead of leaving the user to discover it. The success message uses role="status" rather than alert — it is polite news, not an interruption.

The rate limit that actually holds

An unprotected contact form is an open mail relay pointed at your own inbox. The example everyone copies looks like this:

const seen = new Map<string, number[]>(); // ← wrong on serverless

An in-memory Map is per-instance. Your Vercel function scales to twenty instances under load and a bot gets twenty times the limit; the instance recycles and the counter resets. It measures nothing.

Use a shared store. Any Redis works; the durable-storage detail matters more than the vendor:

import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
import { headers } from "next/headers";

const limiter = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(5, "10 m"),
});

async function clientIp() {
  const h = await headers(); // async in Next.js 15+
  const forwarded = h.get("x-forwarded-for");
  return forwarded?.split(",")[0]?.trim() ?? "unknown";
}

Then, first thing in the action:

const { success } = await limiter.limit(`contact:${await clientIp()}`);
if (!success) return { ok: false, errors: { form: "Too many messages. Try again later." } };

Two caveats. x-forwarded-for is client-supplied and only trustworthy because your platform's proxy overwrites it — if you self-host behind your own nginx, make sure it does the same, or the header is a suggestion. And when everything falls back to "unknown", you have built one shared bucket for the whole internet; prefer your platform's real client-IP header where it offers one.

Sending the mail, and the from address trap

import { Resend } from "resend";

const resend = new Resend(process.env.RESEND_API_KEY);

async function deliver(data: { name: string; email: string; message: string }) {
  const { error } = await resend.emails.send({
    // Your verified domain. NOT the visitor's address.
    from: "ASoc Contact <contact@example.com>",
    to: ["inbox@example.com"],
    replyTo: data.email,
    subject: `Contact form — ${data.name}`,
    text: `${data.name} <${data.email}>\n\n${data.message}`,
  });
  if (error) throw new Error(error.message);
}

The single most common bug in contact-form code is putting the visitor's address in from, because it makes "reply" work in your inbox. It also means you are sending mail claiming to be someone@gmail.com from a server Gmail never authorized, which fails SPF and DKIM alignment and gets the message spam-foldered or rejected outright. Your form appears to work and the mail quietly never arrives.

Send as your own verified domain and set replyTo. Reply still works, and the message passes authentication.

Two more, while you are in here. Send plain text, or escape the message if you build HTML — a message body containing markup will otherwise render in whatever reads your mail. And put the API key in RESEND_API_KEY without the NEXT_PUBLIC_ prefix; anything prefixed that way is inlined into the client bundle and published.

What this costs your page

Nothing, above the fold. The Server Action lives on the server; the only JavaScript is the small form component, and only that component is a Client Component.

If your contact form sits at the bottom of a long landing page, keep it out of the initial payload entirely:

import dynamic from "next/dynamic";
const ContactForm = dynamic(() => import("@/components/ContactForm"));

The rest of the page stays statically rendered. That is the whole reason to prefer this over a third-party embedded form widget, which typically arrives as a render-blocking script with its own font and its own layout shift, on every page it appears on.

Mistakes and how they show up

MistakeSymptomFix
Visitor's address in fromMail silently spam-folderedVerified domain in from, visitor in replyTo
useFormState from react-domImport error on React 19useActionState from react
In-memory rate-limit MapLimit does nothing in productionRedis or another shared store
Returning the provider errorKeys and hostnames on a public pageLog server-side, return a generic sentence
NEXT_PUBLIC_RESEND_API_KEYKey shipped in the client bundleDrop the prefix; rotate the key
Honeypot returns an errorBots adapt within daysReturn the success response
onSubmit handler instead of actionForm dead before hydrationPass the action to <form action>
No aria-invalid / aria-describedbyErrors invisible to screen readersWire both, plus role="alert"
Trusting x-forwarded-for unconditionallyLimit bypassed by a headerRely on the platform's overwrite, or its own IP header

Frequently asked questions

Do Server Actions work without JavaScript? Yes, when the action is passed directly to <form action={…}>. Next.js renders a real form post and runs the action on the server. That is why onSubmit with preventDefault is the wrong shape here — it throws the guarantee away for no gain.

Do I need an API route as well? No. An API route is the right choice when something outside your app posts to the endpoint — a third-party webhook, a mobile client. For your own form, the action is less code and less surface area.

Zod, or something else? Anything that validates on the server. The library matters less than the rule: never trust required in the markup, because the request does not have to come from your form.

How do I add a CAPTCHA? Add it only if the honeypot plus rate limiting stops holding. When you do, verify the token inside the Server Action before doing anything else, and treat a verification failure exactly like the honeypot — quiet success, no mail.

Where should the submission be stored? Email alone loses messages to spam filters and to whoever is on holiday. If the form matters commercially, write the row to your database first and send the mail second, so a delivery failure never destroys the lead.

Starting from a page that already has one

Every landing page ends in a form, and the form is where the interesting failures live — deliverability, bots, accessibility, and the layout shift a third-party embed drags in.

Our Next.js landing templates ship the funnel already built. ASoc Cortex is an AI-agency site with services, industries, case studies and a lead funnel; ASoc Momentum is an AI-consulting site closing on a contact funnel; ASoc Keystone is a mortgage-lender site with an eligibility checker and a quote funnel.

See all Next.js landing page templates, or the Tailwind landing page templates.

Keep reading

Tutorial10 min read

A Next.js Content Security Policy That Keeps Static Rendering

The documented nonce recipe turns every route it touches dynamic. The static-safe policy we ship instead, what 'unsafe-inline' really costs, and what the header still blocks.

Read more
Tutorial10 min read

Next.js Currency Formatting: Four Renderings, One That Lied

Four ways to print money in one codebase, and the fallback that stamped a dollar sign on any currency Intl could not parse. The audit, the fix, and the locale rule.

Read more