Skip to content
FormSubmit

Next.js

How to build a Next.js contact form (without API routes)

Add a working contact form to a Next.js App Router site without writing a route handler, server action or email integration. Works with static export.

2 min readUpdated Sep 28, 2026

The simplest way to add a contact form to Next.js is a small client component that POSTs the form's FormData to a hosted form endpoint and reads the JSON response. You don't need a Route Handler, a Server Action, an email provider or a database — and because the request goes straight from the browser to FormSubmit, it works with output: "export" static sites too.

Why skip the Route Handler?

A Next.js route handler that sends email looks simple in a tutorial, but in production you also need: an email service with a verified domain, spam protection (bots find every form), somewhere to store submissions when email fails, retries, rate limiting, and a way to see past submissions. FormSubmit provides all of that behind one URL, on every plan.

1. Create the form endpoint

Sign in to FormSubmit, create a form, and copy its endpoint — for example https://formsubmit.app/f/k3m9p2qabx. You can store it in an environment variable (NEXT_PUBLIC_FORMSUBMIT_URL) or just paste it into the component; the endpoint isn't a secret.

2. Create the client component

Create app/contact/contact-form.tsx:

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

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

const ENDPOINT = "https://formsubmit.app/f/YOUR_FORM_ID";

export default function ContactForm() {
  const [status, setStatus] = useState<"idle" | "sending" | "success" | "error">("idle");
  const [error, setError] = useState("");
  const [renderedAt] = useState(() => Date.now());

  async function onSubmit(e: FormEvent<HTMLFormElement>) {
    e.preventDefault();
    setStatus("sending");
    const data = new FormData(e.currentTarget);
    data.set("_ts", String(renderedAt)); // helps FormSubmit's time-based spam check
    try {
      const res = await fetch(ENDPOINT, {
        method: "POST",
        body: data,
        headers: { Accept: "application/json" },
      });
      const json = await res.json();
      if (!res.ok || !json.ok) throw new Error(json.error?.message ?? "Something went wrong");
      setStatus("success");
      e.currentTarget.reset();
    } catch (err) {
      setError((err as Error).message);
      setStatus("error");
    }
  }

  if (status === "success") {
    return <p className="text-green-700">Thanks! We'll be in touch soon.</p>;
  }

  return (
    <form onSubmit={onSubmit} className="mx-auto max-w-lg space-y-5">
      <div className="space-y-1.5">
        <label htmlFor="fs-name" className="block text-sm font-medium text-gray-900">Name</label>
        <input id="fs-name" type="text" name="name" placeholder="Jane Doe" required className="block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20" />
      </div>
      <div className="space-y-1.5">
        <label htmlFor="fs-email" className="block text-sm font-medium text-gray-900">Email</label>
        <input id="fs-email" type="email" name="email" placeholder="jane@example.com" required className="block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20" />
      </div>
      <div className="space-y-1.5">
        <label htmlFor="fs-message" className="block text-sm font-medium text-gray-900">Message</label>
        <textarea id="fs-message" name="message" rows={5} placeholder="How can we help?" required className="block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/20"></textarea>
      </div>
      {/* Honeypot: bots fill this in, humans never see it */}
      <input type="text" name="_gotcha" tabIndex={-1} autoComplete="off" style={{ display: "none" }} />
      {status === "error" && <p className="text-sm text-red-600">{error}</p>}
      <button type="submit" disabled={status === "sending"} className="inline-flex w-full items-center justify-center rounded-lg bg-gray-900 px-4 py-2.5 text-sm font-medium text-white hover:bg-gray-800 disabled:opacity-50">
        {status === "sending" ? "Sending…" : "Send"}
      </button>
    </form>
  );
}

What this does:

  • "use client" — the form needs state for loading and success messages.
  • FormData collects every named input, including files.
  • Accept: application/json tells FormSubmit to reply with { ok, id, message } instead of a redirect.
  • _ts records when the form rendered, powering FormSubmit's time-to-submit spam check.
  • The hidden _gotcha input is a honeypot.

3. Use it in a page

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

export const metadata: Metadata = { title: "Contact us" };

export default function ContactPage() {
  return (
    <main className="mx-auto max-w-xl px-4 py-16">
      <h1 className="mb-8 text-3xl font-semibold">Contact us</h1>
      <ContactForm />
    </main>
  );
}

The page stays a Server Component; only the form hydrates.

4. Progressive enhancement

Add action and method to the <form> so it still works before hydration or if JavaScript fails:

tsx
<form action="https://formsubmit.app/f/YOUR_FORM_ID" method="POST" onSubmit={onSubmit}>

When JavaScript runs, onSubmit calls preventDefault() and uses fetch; otherwise the browser posts normally and FormSubmit redirects to your thank-you page.

5. Using shadcn/ui or react-hook-form

Nothing changes on the FormSubmit side: collect the values however you like and send them as FormData or JSON.

ts
await fetch(ENDPOINT, {
  method: "POST",
  headers: { "Content-Type": "application/json", Accept: "application/json" },
  body: JSON.stringify(values),
});

JSON bodies can't include files — use FormData for uploads, or direct uploads for large files.

6. Lock it down

In the form's settings, add your production domain (and localhost for development) to the domain allowlist. Consider Turnstile if your form is heavily targeted.

Going further

  • Send leads to Slack or Google Sheets.
  • Trigger your own code with signed webhooks — a Next.js route handler can verify the signature and update your database.
  • Build the form visually in the form generator and export the Next.js component.

Frequently asked questions

Why not use a Server Action or Route Handler?

You can, but then you own email delivery, spam filtering, storage and retries. Posting directly to a form backend means zero server code and it works even with output export (static HTML).

Does this work with next export / output export?

Yes. The component submits from the browser directly to FormSubmit, so no Next.js server is required.

Can I keep the form working without JavaScript?

Yes. Keep action and method on the form element; if hydration hasn't happened yet, the browser submits normally and FormSubmit redirects.

Keep reading

Your form backend is 60 seconds away

Sign in with Google, create a form, paste the endpoint. Free forever for small sites — no credit card.