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.
<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.
"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
The plain form action works inside any Next.js page or layout, including Server Components. No app/api/contact/route.ts, no Server Action, no middleware. If you want a client-side UX with success and error states, the second snippet shows the canonical pattern: "use client", useState, onSubmit, fetch to formhook.app/f/YOUR_API_KEY. The honeypot (_gotcha) and optional _redirect are the same special fields from the HTML guide.
Plain form or fetch: which one should you use?
Use the plain action form when you want zero JavaScript: it works in a Server Component and keeps working if hydration fails. The trade-off is that the browser leaves your page on submit, so add a hidden _redirect input with an absolute URL on one of your allowed origins, such as https://example.com/thanks. Without it, visitors land on a raw JSON response.
Use the fetch version when you want the success or error message to appear in place, with a disabled button while sending. Plain-form posts can redirect; JSON fetch posts get a JSON answer back, so _redirect is ignored there. The longer write-up is AJAX form submission.
Fix 403 origin_not_allowed in local development
Formhook only accepts browser submissions from origins you list under your form's Allowed origins, and the match is exact: scheme, host and port. http://localhost:3000, https://example.com and https://www.example.com are three different origins. Add your dev URL, your production domain and any preview-deployment domain you test on, without a path or trailing slash. An empty list rejects every browser request.
If a submission still fails, the response body says why: origin_not_allowed (403) means the origin is missing from the list, form_not_found (404) means the API key is wrong, and rate_limited (429) means wait for the Retry-After seconds. More in Contact form not working?
Where to put the API key
The key ends up in your HTML or JavaScript bundle either way, so treat it as a public identifier, not a secret. A NEXT_PUBLIC_FORMHOOK_KEY environment variable is still handy for using one form in development and another in production.
What protects the form lives on Formhook's side: the allowed-origins list, the _gotcha honeypot, per-IP and per-form rate limits, and optional Cloudflare Turnstile. The origin list keeps other websites from posting from a browser, but it is not authentication, because a script can send any Origin header. Rely on the spam layers for bots; spam protection for contact forms compares them.
Submitting from a Server Action instead
If you want to add server-side data or validate before sending, call Formhook from a Server Action or route handler. Server requests send no Origin header, so they must carry an X-Auth-Token header, generated under your form's settings. Keep that token in a server-only environment variable, never in a Client Component. Most contact forms don't need this, which is why the snippets above skip it. The docs have the full request format.
Before you go live
- Add the production origin, with
https://, to Allowed origins. - Keep the
_gotchahoneypot field and decide whether to turn on Turnstile. - Send a test submission and confirm it shows up in the dashboard and as a push notification.
- Add a short privacy notice under the form. The GDPR contact form guide has wording you can adapt.
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 freeFree 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?
Can I use this in a Server Component?
How do I handle spam without writing rate-limit middleware?
Guides for other frameworks
Migrating from Formspree? See how Formhook compares.