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.
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.
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.

