Tutorials

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

December 23, 20257 min read
Building a contact form in Next.js

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:

  1. A Route Handler + email API (Resend, SendGrid, etc.): you own the backend code. Full control, but also full responsibility.
  2. Server Actions: runs server-side, which is nice, but you still need to wire up email sending and spam protection yourself.
  3. 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>
Note

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>
  );
}
Tip

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: fetch with FormData and a "loading" | "success" | "error" state
  • For complex forms: react-hook-form wraps 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