Skip to content
Formhook
← All guides

Guide

Next.js contact form (no backend needed)

Next.js doesn't need its own form receiver. You can drop the same plain HTML form action into any Server Component, or - if you want client-side success and error states - use a small Client Component with fetch. Both POST directly to Formhook.

Use a plain HTML form

You do not need an API route. The form POSTs directly to Formhook, and your framework never has to touch the submission.

contact.html
<form action="https://formhook.app/f/YOUR_API_KEY" method="POST">
  <label>
    Email
    <input type="email" name="email" required />
  </label>

  <label>
    Message
    <textarea name="message" required></textarea>
  </label>

  <!-- honeypot: bots fill this; humans don't see it -->
  <input type="text" name="_gotcha" tabindex="-1" autocomplete="off" hidden />

  <!-- optional: send users to a thank-you page after a native POST -->
  <input type="hidden" name="_redirect" value="https://example.com/thanks" />

  <button type="submit">Send</button>
</form>

Or submit with fetch in Next.js

Use this version if you want client-side success/error states without a full page reload.

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

import { useState } from "react";

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

  async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    setStatus("sending");

    const form = e.currentTarget;
    const data = Object.fromEntries(new FormData(form));

    const res = await fetch("https://formhook.app/f/YOUR_API_KEY", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(data),
    });

    setStatus(res.ok ? "ok" : "error");
    if (res.ok) form.reset();
  }

  if (status === "ok") return <p>Thanks - we'll be in touch.</p>;

  return (
    <form onSubmit={onSubmit}>
      <input type="email" name="email" placeholder="Email" required />
      <textarea name="message" placeholder="Message" required />
      <input type="text" name="_gotcha" tabIndex={-1} hidden />
      <button type="submit" disabled={status === "sending"}>
        {status === "sending" ? "Sending…" : "Send"}
      </button>
      {status === "error" && <p>Something went wrong. Please try again.</p>}
    </form>
  );
}

How it works

Plain form or fetch: which one should you use?

Fix 403 origin_not_allowed in local development

Where to put the API key

Submitting from a Server Action instead

Before you go live

What happens next

  • Submission appears in your Formhook dashboard within a second.
  • Push notification fires on every device you've enabled it on.
  • Reply directly from the dashboard (Pro and above) - handy for client work where each project has its own contact route.

Ship your Next.js contact form today.

Sign up free, create a form, paste the API key. Five minutes.

Start free

Free tier: 5 forms · 250 submissions/month · No credit card.

Next.js contact form - FAQ

Do I need to add an API route or a Server Action?
No. The form POSTs to Formhook directly. Skipping the API route also means one less surface to rate-limit yourself, and one less cold start to pay for on serverless deployments.
Can I use this in a Server Component?
Yes - the plain HTML form snippet works in any Server Component. Only the fetch-based version needs to be a Client Component, because it uses useState.
How do I handle spam without writing rate-limit middleware?
Spam protection is on Formhook's side. The honeypot drops bots silently, and Cloudflare Turnstile is a per-form toggle in the dashboard. Per-IP and per-form rate limits are also enforced server-side.

Guides for other frameworks

Migrating from Formspree? See how Formhook compares.

Cookie preferences

We use a strictly-necessary session cookie and cookieless first-party analytics. With your consent we also run Google Analytics to understand traffic on our marketing pages.