Next.js contact form to email

In Next.js the form is a client component that posts straight from the browser to FormRoute. You don't need an API route or a server action, and the visitor's own address is what the rate limits count.

You need a FormRoute account with a verified domain and a form whose fields are name, email and message, or change the names below to match yours. Getting started covers the setup.

1. Add your site key

Put the site key from your FormRoute account in .env.local. It is public by design, so the NEXT_PUBLIC_ prefix is right.

.env.local
NEXT_PUBLIC_FORMROUTE_SITE_KEY=fr_your_site_key

2. Create the form component

A client component with the three fields, the hidden hp honeypot and the timing value. The name attributes must match the fields you set up in FormRoute.

app/contact/contact-form.tsx
"use client";

import { useEffect, useRef, useState, type FormEvent } from "react";

const ENDPOINT = "https://form-route.com/api/v1/send";
const SITE_KEY = process.env.NEXT_PUBLIC_FORMROUTE_SITE_KEY!;

export function ContactForm() {
  const shownAt = useRef(0);
  const [status, setStatus] = useState<"idle" | "sending" | "sent" | "error">("idle");

  // Start the clock when the form is on screen.
  useEffect(() => {
    shownAt.current = Date.now();
  }, []);

  async function onSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const form = event.currentTarget;
    const data = new FormData(form);
    setStatus("sending");

    try {
      const response = await fetch(ENDPOINT, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          siteKey: SITE_KEY,
          t: Date.now() - shownAt.current,
          hp: data.get("hp") ?? "",
          name: data.get("name"),
          email: data.get("email"),
          message: data.get("message"),
        }),
      });

      if (response.ok) {
        form.reset();
        setStatus("sent");
      } else {
        setStatus("error");
      }
    } catch {
      setStatus("error");
    }
  }

  return (
    <form onSubmit={onSubmit}>
      <label>Name <input name="name" required maxLength={120} /></label>
      <label>Email <input name="email" type="email" required maxLength={254} /></label>
      <label>Message <textarea name="message" required maxLength={5000} /></label>

      {/* Honeypot: hidden from people, must stay empty */}
      <div aria-hidden="true" style={{ position: "absolute", left: -10000 }}>
        <label>Leave this empty <input name="hp" tabIndex={-1} autoComplete="off" /></label>
      </div>

      <button type="submit" disabled={status === "sending"}>Send</button>

      <p role="status">
        {status === "sent" && "Thanks, your message was sent."}
        {status === "error" && "Sorry, that didn't go through. Please try again."}
      </p>
    </form>
  );
}

3. Use it on a page

Import the component into any page or layout. The page itself can stay a server component.

app/contact/page.tsx
import { ContactForm } from "./contact-form";

export default function ContactPage() {
  return (
    <main>
      <h1>Contact us</h1>
      <ContactForm />
    </main>
  );
}

4. List your website in FormRoute

In your FormRoute account, open your form and add every address the form is served from to Allowed websites, for example https://www.example.com and, while you develop, http://localhost:3000. Each entry is a scheme and host, with a port only if your site uses one.

A browser submission from an address that isn't listed is refused, and your account shows it as Rejected: website not allowed. For local development, use the exact address your dev server prints.

5. Send a test

Open the page, fill in the form and send it. The submission appears on your FormRoute home page within seconds, as Sent or with the reason it was refused, and the email arrives from your own domain.

Fill the form in at a human pace: a submission sent within 1.2 seconds of the form appearing is treated as automated.

Good to know

  • Post from the browser, not from a route handler or server action. A server-side call reaches FormRoute from your server's address, so every visitor would share one rate limit.
  • Preview deployments have their own addresses. Add one to Allowed websites only while you test it.
  • Deploying? Set the same environment variable in your host's settings (for example your Vercel or Netlify project): .env files on your machine are not deployed. Or put the key in the component as a constant; it is public by design.

More guides

HTML · React · Vue · Astro · WordPress · Webflow · Framer · Hugo, Jekyll and Eleventy