How to build a contact form in Next.js (without a backend)

Every Next.js project eventually needs a contact form. And the first instinct is usually to open a new file under app/api/ and start building a handler that calls some email API.
That works. But it's also a whole project in itself. You need to pick an email provider, handle CORS, deal with spam, manage delivery failures, and build a way to review what was submitted. Before you've written a single line of your actual product.
There's a cleaner way. Let me show you.
The options, quickly
When you search "Next.js contact form", you'll find three common approaches:
- A Route Handler + email API (Resend, SendGrid, etc.): you own the backend code. Full control, but also full responsibility.
- Server Actions: runs server-side, which is nice, but you still need to wire up email sending and spam protection yourself.
- A form backend service: you point your form at a hosted endpoint. Submissions are stored and forwarded to you. No backend code to write.
For most projects, especially static sites, marketing pages, and SaaS landing pages, option 3 is the right call. You ship faster, and you get spam filtering and email notifications without building them.
If you've wondered what that full backend responsibility actually looks like in practice, Stop building your own form backend walks through the honest accounting.
This guide covers option 3 using Formtorch, but the fetch pattern applies to any form backend.
The simplest version: HTML only
If your contact form is on a mostly static page and you don't need loading/success states, 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 a working form. The browser handles the submission natively, and Formtorch redirects to a default thank-you page after.
If you want to redirect to your own page instead:
<form action="https://formtorch.com/f/YOUR_FORM_ID" method="POST">
<input
type="hidden"
name="_redirect"
value="https://yoursite.com/thank-you"
/>
<!-- your fields -->
</form>Fields prefixed with _ are metadata. They control Formtorch behavior but are
never shown in the submission. _redirect is how you send people to your own
page after submitting.
For a lot of use cases, this is enough. But React apps usually want something more interactive.
A proper React contact form
Here's a complete, production-ready contact form component. It handles loading, success, and error states, which are the things that matter most for UX.
// components/ContactForm.tsx
"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("Submission failed");
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}>
<div>
<label htmlFor="name">Name</label>
<input id="name" name="name" type="text" required />
</div>
<div>
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" required />
</div>
<div>
<label htmlFor="message">Message</label>
<textarea id="message" name="message" rows={5} required />
</div>
{state === "error" && <p>Something went wrong. Please try again.</p>}
<button type="submit" disabled={state === "loading"}>
{state === "loading" ? "Sending…" : "Send message"}
</button>
</form>
);
}A few things worth noting here:
X-Requested-With: XMLHttpRequest: this header tells Formtorch to respond with JSON instead of an HTML redirect. Without it, a 200 response still works, but you'd get HTML back which isn't useful in a fetch handler.
FormData over JSON: FormData works with any input type including file uploads, and you don't need to manually list every field. If you add or remove a field later, the submission still works without touching this code.
disabled={state === "loading"}: a small detail, but it prevents double-submissions when someone clicks twice.
Where to get your form ID
You'll need a Formtorch account and a form created in the dashboard. Once you create a form, you get an endpoint URL like https://formtorch.com/f/abc123 immediately. No extra configuration needed.
Create a project
Sign in at formtorch.com and click New Project. Projects are just containers for your forms.
Create a form
Inside your project, click New Form. Give it a name. The endpoint URL appears immediately.
Copy the endpoint
Your endpoint looks like https://formtorch.com/f/abc123. Swap
YOUR_FORM_ID in the code above for the ID in that URL.
Set up notification emails
Go to Form Settings → Notifications and add your email. Confirm it, and you'll get an email for each submission.
Handling validation properly
The form above handles server-side failure states. But you'll probably want client-side validation too. Here's how to layer it in without rewriting everything:
"use client";
import { useState } from "react";
type FormState = "idle" | "loading" | "success" | "error";
type Errors = Partial<Record<"name" | "email" | "message", string>>;
function validate(data: FormData): Errors {
const errors: Errors = {};
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 = "Enter a valid email address.";
if (!data.get("message")) errors.message = "Message is required.";
return errors;
}
export function ContactForm() {
const [state, setState] = useState<FormState>("idle");
const [errors, setErrors] = useState<Errors>({});
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const data = new FormData(e.currentTarget);
const errs = validate(data);
if (Object.keys(errs).length > 0) {
setErrors(errs);
return;
}
setErrors({});
setState("loading");
try {
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 <p>Thanks for reaching out. I'll get back to you soon.</p>;
}
return (
<form onSubmit={handleSubmit} noValidate>
<div>
<label htmlFor="name">Name</label>
<input id="name" name="name" type="text" />
{errors.name && <span>{errors.name}</span>}
</div>
<div>
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" />
{errors.email && <span>{errors.email}</span>}
</div>
<div>
<label htmlFor="message">Message</label>
<textarea id="message" name="message" rows={5} />
{errors.message && <span>{errors.message}</span>}
</div>
{state === "error" && <p>Something went wrong. Please try again.</p>}
<button type="submit" disabled={state === "loading"}>
{state === "loading" ? "Sending…" : "Send message"}
</button>
</form>
);
}The noValidate attribute on the form turns off the browser's built-in validation UI, so you can control the error presentation yourself.
For an architecture that handles multiple forms consistently with shared Zod schemas, see How to design a scalable form validation system.
Using react-hook-form
If your form is more complex, with multiple steps, dynamic fields, or lots of validation, react-hook-form integrates cleanly with the same fetch pattern:
"use client";
import { useForm } from "react-hook-form";
type Fields = {
name: string;
email: string;
message: string;
};
export function ContactForm() {
const {
register,
handleSubmit,
formState: { errors, isSubmitting, isSubmitSuccessful },
} = useForm<Fields>();
async function onSubmit(data: Fields) {
const formData = new FormData();
Object.entries(data).forEach(([k, v]) => formData.append(k, v));
const res = await fetch("https://formtorch.com/f/YOUR_FORM_ID", {
method: "POST",
headers: { "X-Requested-With": "XMLHttpRequest" },
body: formData,
});
if (!res.ok) throw new Error("Submission failed");
}
if (isSubmitSuccessful) {
return <p>Message sent. Talk soon.</p>;
}
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("name", { required: "Name is required" })} />
{errors.name && <span>{errors.name.message}</span>}
<input
type="email"
{...register("email", { required: "Email is required" })}
/>
{errors.email && <span>{errors.email.message}</span>}
<textarea {...register("message", { required: "Message is required" })} />
{errors.message && <span>{errors.message.message}</span>}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Sending…" : "Send message"}
</button>
</form>
);
}When react-hook-form throws from onSubmit to signal failure,
isSubmitSuccessful stays false. You can check formState.errors for the
server error. You don't need a separate error state variable.
What about spam?
Formtorch runs TorchWarden on every submission. It's a scoring system that checks for common spam signals (bots that fill hidden fields, rapid-fire identical submissions, keyword patterns, etc.) without adding friction for real users. You don't opt in; it's on by default.
If you're building something custom instead, the most practical starting point is a honeypot field: a hidden input that legitimate users never fill, but bots usually do.
<!-- Add this inside your form. Hide it with CSS, not type="hidden" -->
<input
name="_honeypot"
tabIndex={-1}
autoComplete="off"
style={{ display: "none" }}
/>For a deeper dive on form spam and how to prevent it, see How to prevent spam in contact forms.
Wrapping up
The short version:
- For simple pages: plain HTML form pointing at a Formtorch endpoint
- For React apps:
fetchwithFormDataand a"loading" | "success" | "error"state - For complex forms:
react-hook-formwraps around the same fetch pattern
You don't need a backend to have a working, spam-filtered contact form with email notifications. The time you'd spend building that is better spent on your actual product.
Need a form endpoint?
Create a free Formtorch account and get your endpoint URL in under two minutes.
Related posts

The complete guide to contact forms for developers
