Every Next.js project eventually needs a contact form.
And the first instinct is usually:
“I’ll just create an API route and send an email.”
That works.
But it’s also a whole project on its own.
You need to:
- pick an email provider
- manage API keys
- deal with spam
- handle failures
- store submissions somewhere
All before you've written a single line of your actual product.
There’s a simpler way.
The options, quickly
When you search “Next.js contact form”, you’ll usually see three approaches:
Route Handler + email API (Resend, SendGrid, etc.)
Full control, but you own everything.Server Actions
Cleaner DX, but you still handle email + spam yourself.Form backend service
Send data to a hosted endpoint. It handles storage + email.
For most projects, especially landing pages, SaaS sites, and static apps, option 3 is the fastest path.
This guide walks through that approach.
The simplest version: HTML only
If you don’t need loading states or interactivity, you can use a plain HTML form with no JavaScript at all:
<form action="https://formtorch.com/f/YOUR_FORM_ID" method="POST">
<input name="name" type="text" placeholder="Your name" required />
<input name="email" type="email" placeholder="Your email" required />
<textarea name="message" placeholder="Message" required></textarea>
<button type="submit">Send message</button>
</form>
That’s already a working form.
The browser handles submission, and you get redirected afterward.
Redirect to your own page
<input
type="hidden"
name="_redirect"
value="https://yoursite.com/thank-you"
/>
For many use cases, this is enough.
But React apps usually want something more interactive.
A proper React contact form
Here’s a production-ready component with loading, success, and error states:
"use client";
import { useState } from "react";
type FormState = "idle" | "loading" | "success" | "error";
export function ContactForm() {
const [state, setState] = useState<FormState>("idle");
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setState("loading");
try {
const data = new FormData(e.currentTarget);
const res = await fetch("https://formtorch.com/f/YOUR_FORM_ID", {
method: "POST",
headers: { "X-Requested-With": "XMLHttpRequest" },
body: data,
});
if (!res.ok) throw new Error();
setState("success");
} catch {
setState("error");
}
}
if (state === "success") {
return (
<div>
<h3>Message sent.</h3>
<p>Thanks for reaching out. I’ll get back to you soon.</p>
</div>
);
}
return (
<form onSubmit={handleSubmit}>
<label htmlFor="name">Name</label>
<input id="name" name="name" required />
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" required />
<label htmlFor="message">Message</label>
<textarea id="message" name="message" required />
{state === "error" && <p>Something went wrong. Try again.</p>}
<button disabled={state === "loading"}>
{state === "loading" ? "Sending…" : "Send message"}
</button>
</form>
);
}
Why this works well
-
FormDatainstead of JSON: Automatically captures all fields (including files). -
X-Requested-Withheader: Ensures the response is JSON instead of an HTML redirect. - Loading + disabled button: Prevents double submissions.
Adding validation
You can layer client-side validation without changing the structure:
function validate(data: FormData) {
const errors: Record<string, string> = {};
if (!data.get("name")) errors.name = "Name is required";
const email = String(data.get("email") ?? "");
if (!email) errors.email = "Email is required";
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email))
errors.email = "Invalid email";
if (!data.get("message")) errors.message = "Message is required";
return errors;
}
Add noValidate to disable browser UI and control error rendering yourself.
Using react-hook-form
For more complex forms:
import { useForm } from "react-hook-form";
export function ContactForm() {
const { register, handleSubmit, formState } = useForm();
async function onSubmit(data: any) {
const formData = new FormData();
Object.entries(data).forEach(([k, v]) =>
formData.append(k, String(v))
);
await fetch("https://formtorch.com/f/YOUR_FORM_ID", {
method: "POST",
headers: { "X-Requested-With": "XMLHttpRequest" },
body: formData,
});
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("name", { required: true })} />
<input type="email" {...register("email", { required: true })} />
<textarea {...register("message", { required: true })} />
<button disabled={formState.isSubmitting}>
{formState.isSubmitting ? "Sending…" : "Send"}
</button>
</form>
);
}
What about spam?
Every public form gets spam.
Common approaches:
CAPTCHA (bad UX)
honeypots
rate limiting
spam detection
A simple honeypot field:
<input name="_honeypot" style="display:none" />
Bots fill it. Humans don’t.
The takeaway
You don’t need to build a backend just to send a contact form email.
The practical setup:
- Simple page → HTML form
- React app →
fetch + FormData - Complex form →
react-hook-form
Everything else is just plumbing.
Optional: skipping the backend entirely
If you don’t want to deal with email APIs, spam filtering, or storing submissions, you can use a hosted form backend.
For example, Formtorch gives you:
- a ready-to-use endpoint
- email notifications
- built-in spam filtering
So your form works without any server code.
If you want to try it:
👉 Formtorch
Either way, once you understand the flow, contact forms stop being a “setup chore” and become a solved problem.
Top comments (0)