Send a form to your email6 min read
A Next.js contact form that sends email
Two ways to email a Next.js form with a Server Action: send it yourself with Resend or Nodemailer, or forward it to a form backend, compared honestly.
In the App Router, a Server Action receives the form's FormData on the server, where an email API key is safe. You can send the email yourself with Resend or Nodemailer, or forward the post to a form backend that filters spam, stores it and emails you. Sending it yourself gives you full control of the email; forwarding saves you the spam filtering, rate limits and storage you'd otherwise build.
What's different here
Next.js already has a server, so the usual reason for a form backend, "my site can't run code", doesn't apply. The question is what that code has to do. Sending the email is the easy part. Everything around it is what takes the time:
- Sending it yourself: the action validates the fields, checks the honeypot and calls your email provider with an API key. The message exists only as that email.
- Forwarding it: the action posts the fields to Ikiform with your secret key. Ikiform validates against the form's definition, scores spam, stores the submission and emails the recipients.
| Job | Server Action + Resend or Nodemailer | Server Action + Ikiform |
|---|---|---|
| Validation | Zod in the action | The form's field definition, enforced on Ikiform's side |
| Spam | Your honeypot; anything more is yours to add | Honeypot, spam scoring and an optional human check |
| Rate limits | None until you add a store such as Redis | Built in, per visitor and per form |
| Sending domain | Verify yours with SPF and DKIM records | None: mail comes from notifications@ikiform.com |
| Subject and design | Anything you like | Fixed: New submission: <form name> |
| Confirmation to the visitor | One more send call | Not available; send it from your email tool |
| A copy of every message | Only if you add a database | Dashboard, CSV export and API |
Pick the DIY version for a branded email or a confirmation to the visitor, if you're happy to own spam and storage. Pick forwarding to stop thinking about the form once it works.
Both versions
The form is a project inquiry: name, work email, company, a budget range and a description. The state type and the client component are shared; only actions.ts changes.
export interface InquiryState {
sent: boolean;
errors: Partial<Record<"name" | "email" | "company" | "budget" | "message" | "form", string>>;
}
export const initialInquiry: InquiryState = { sent: false, errors: {} };"use client";
import { useActionState } from "react";
import { useFormStatus } from "react-dom";
import { sendInquiry } from "./actions";
import { initialInquiry } from "./state";
function SubmitButton() {
const { pending } = useFormStatus();
return <button disabled={pending}>{pending ? "Sending" : "Send inquiry"}</button>;
}
export function InquiryForm() {
const [state, action] = useActionState(sendInquiry, initialInquiry);
if (state.sent) return <p role="status">Thanks. We reply to every inquiry within two working days.</p>;
return (
<form action={action}>
<label htmlFor="name">Name</label>
<input id="name" name="name" autoComplete="name" required />
{state.errors.name ? <p role="alert">{state.errors.name}</p> : null}
<label htmlFor="email">Work email</label>
<input id="email" name="email" type="email" autoComplete="email" required />
{state.errors.email ? <p role="alert">{state.errors.email}</p> : null}
<label htmlFor="company">Company</label>
<input id="company" name="company" autoComplete="organization" />
<label htmlFor="budget">Budget</label>
<select id="budget" name="budget" required defaultValue="">
<option value="" disabled>Choose a range</option>
<option value="under-5k">Under $5k</option>
<option value="5k-20k">$5k to $20k</option>
<option value="over-20k">Over $20k</option>
</select>
{state.errors.budget ? <p role="alert">{state.errors.budget}</p> : null}
<label htmlFor="message">About the project</label>
<textarea id="message" name="message" rows={6} required minLength={20} />
{state.errors.message ? <p role="alert">{state.errors.message}</p> : null}
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" hidden />
{state.errors.form ? <p role="alert">{state.errors.form}</p> : null}
<SubmitButton />
</form>
);
}"use server";
import { Resend } from "resend";
import { z } from "zod";
import type { InquiryState } from "./state";
const resend = new Resend(process.env.RESEND_API_KEY);
const schema = z.object({
name: z.string().trim().min(1, "Tell us your name.").max(120),
email: z.email("Enter an address we can reply to."),
company: z.string().trim().max(120).optional(),
budget: z.enum(["under-5k", "5k-20k", "over-20k"], "Pick a budget range."),
message: z.string().trim().min(20, "A few sentences help us reply properly.").max(5000),
});
export async function sendInquiry(_previous: InquiryState, formData: FormData): Promise<InquiryState> {
if (formData.get("_gotcha")) return { sent: true, errors: {} };
const parsed = schema.safeParse(Object.fromEntries(formData));
if (!parsed.success) {
const fields = z.flattenError(parsed.error).fieldErrors;
return {
sent: false,
errors: Object.fromEntries(Object.entries(fields).map(([key, messages]) => [key, messages?.[0]])),
};
}
const inquiry = parsed.data;
const { error } = await resend.emails.send({
from: "Website <inquiries@yourdomain.com>",
to: ["you@yourdomain.com"],
replyTo: inquiry.email,
subject: `Project inquiry from ${inquiry.name}`,
text: [
`Name: ${inquiry.name}`,
`Email: ${inquiry.email}`,
`Company: ${inquiry.company ?? "-"}`,
`Budget: ${inquiry.budget}`,
"",
inquiry.message,
].join("\n"),
});
if (error) return { sent: false, errors: { form: "Couldn't send your inquiry. Try again." } };
return { sent: true, errors: {} };
}- The state type lives in its own file because a
"use server"file may only export async functions. useFormStatusreads the pending state of the form it sits in, so the button lives in its own component.- In the Resend version, a filled honeypot returns success without sending, so the bot learns nothing. Ikiform does the same when it stores the post as spam.
replyToin the Resend version, and the field namedemailin the Ikiform version, both make Reply write to the visitor.- Nodemailer works the same way as Resend: create a transport with your SMTP details and call
sendMailwithfrom,to,replyTo,subjectandtext. - For the Ikiform version, define the five fields on the form's Fields tab with
budgetas aselect, so a bad value gets422 validation_failedwith a message per field.
IKIFORM_SECRET_KEY in server-only environment variables. Each server sample passes on the browser's Origin header when there is one, because a server's own fetch sends none and Ikiform counts free localhost test submissions by it.There's a third option: skip the action and let the browser post straight to Ikiform. No secret key is involved, so the Standard rate limit and Allowed domains apply per visitor. The Next.js docs guide shows the client component.
Check it works
- Submit with an empty budget. The error shows under the select and nothing else is lost.
- Submit a real inquiry. The success line replaces the form.
- Open the email and press Reply. The To line is the address you entered.
- Fill in the hidden honeypot with the browser's dev tools and submit. You see success, and no email arrives.
- Open Submissions in the dashboard. The new submission is there with every field you sent. This step applies to the Ikiform version only.
Gotchas
- Escaping in HTML email. The Resend version sends
text. If you switch tohtml, escape every value first, or a visitor can put markup and links into your inbox. - Header injection. Never put a raw form value into
fromorto.replyTois safe with Resend because Zod has already checked it's an email. - Environment variables.
RESEND_API_KEYandIKIFORM_SECRET_KEYmust not start withNEXT_PUBLIC_, or Next.js ships them to the browser. - Duplicate sends. A double click can run the action twice. The pending state disables the button, but a slow network can still race; Resend's
idempotencyKeyoption, or Ikiform'sIdempotency-Keyheader, makes a repeat a no-op. - Server Actions are public endpoints. Anyone can call one directly, which is why validation and spam checks must run inside it, not only in the form.
If something goes wrong
| Problem | What to do |
|---|---|
| Resend answers that the domain isn't verified | Add Resend's DNS records for the domain in from, or send from Resend's test address while you develop. |
429 rate_limited | One visitor sent more than the form's rate limit allows. Standard allows 5 a minute per visitor. Wait for Retry-After, or choose Relaxed or Custom for forms that post from one shared IP. |
422 validation_failed | The post doesn't match the form's field definition. error.fields names each field and the reason. Match the input name to the field id. |
| The action works locally but not in production | The key is missing in the deployment's environment variables. Add it to the production environment and redeploy. |
Questions
Yes, in the Node.js runtime, which Server Actions use by default. It opens an SMTP connection, so it doesn't run on the Edge runtime, and some hosts limit outbound SMTP. An HTTP email API such as Resend avoids both.
Handle the form with a Server Action, which runs on the server. Either call an email API such as Resend or Nodemailer from it, or forward the fields to a form backend that emails you.
Yes, the API key stays on the server as long as its name doesn't start with NEXT_PUBLIC_. Validate every field inside the action, because Server Actions can be called directly.
Not to send email. You do need one if you want a record of messages that survives a lost email. A form backend keeps that record for you.
No. Ikiform emails you and your team only. Send a confirmation from your own email provider, in your Server Action or from a webhook.
Stuck on a step, or want a guide for your situation? Email hello@ikiform.com.