Next.js Form Integration with Form Backend Tool
Most Next.js form guides start by building infrastructure: a Route Handler, a Server Action, a mail library. For a normal contact, lead or quote form, none of that is required here. A Next.js site can send its form straight to a hosted Form Backend endpoint, which stores the submission and emails it to you, and the only real decision is where the submission happens: in the browser as a plain form, or from a small client component with fetch. There is no Next.js SDK and no package to install; the endpoint is the integration. This guide is the Next.js layer on top of the React guide and the HTML integration reference; the concept itself is in What Is a Form Backend?
The decision first
- Native form, browser to endpoint. A plain
<form>with the endpoint as itsaction. The browser submits and navigates to your thank-you page. No JavaScript, no client component, nothing of yours in the middle. This is the right default for most Next.js sites. - Client fetch, browser to endpoint. A small
"use client"component posts JSON and keeps the user on the page, showing progress and errors inline. Choose it when the form lives inside an interactive UI. - Your own Route Handler or Server Action in between. Your server receives the submission and forwards it to the endpoint. This is an extra hop you own and maintain, and it is not required to use the service. It earns its place only when you need server-side work of your own at submit time, for example writing to your own database alongside. Section 4 returns to this.
Option 1: the native form in a Server Component
In the App Router, a page is a Server Component by default, and a form like this needs nothing more. It has no state and no handlers, so "use client" does not belong on it:
// app/contact/page.tsx, a Server Component. No "use client" needed:
// this form has no state and no event handlers.
export default function ContactPage() {
return (
<main>
<h1>Contact us</h1>
<form action="https://form-backend-tool.vercel.app/api/forms/YOUR_FORM_KEY" method="POST">
<label>
Your name <input type="text" name="name" required />
</label>
<label>
Your email <input type="email" name="email" required />
</label>
<label>
Message <textarea name="message" required />
</label>
<input type="hidden" name="_redirect" value="https://your-site.example/thanks" />
<input
type="text"
name="_honey"
style={{ display: "none" }}
tabIndex={-1}
autoComplete="off"
/>
<button type="submit">Send</button>
</form>
</main>
);
}The Next.js-specific points:
actionis a string URL. In Next.js a form'sactioncan also be a function, which turns it into a Server Action handled by your own server. Passing the endpoint URL as a string keeps the browser posting directly to Form Backend, which is the whole point of this option.- The browser does the submission, sending the fields as a normal form post. On success the endpoint answers with a redirect (HTTP 303) to your
_redirectURL, or, without one, its built-in thank-you page. Both behaviours were exercised against a live endpoint while this guide was written. nameattributes carry the data, exactly as in plain HTML; the contact form tutorial explains each field, including the_honeyspam trap.- The form key in the URL is public by design. It is rendered into the page, like the key in the live demo on the homepage. It identifies the form; it is not a credential.
Option 2: a client fetch component
When the submission should not navigate, put the form in one small client component. "use client" is required here, and only here, because this file uses state and a submit handler:
"use client";
import { useState, type FormEvent } from "react";
const ENDPOINT = "https://form-backend-tool.vercel.app/api/forms/YOUR_FORM_KEY";
export default function ContactForm() {
const [status, setStatus] = useState("idle"); // idle | sending | sent | error
const [error, setError] = useState("");
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setStatus("sending");
setError("");
const form = event.currentTarget;
const data = Object.fromEntries(new FormData(form).entries());
try {
const response = await fetch(ENDPOINT, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify(data),
});
const result = await response.json();
if (response.ok && result.ok) {
setStatus("sent");
form.reset();
} else {
setStatus("error");
setError(result.error || "Something went wrong. Please try again.");
}
} catch {
setStatus("error");
setError("Network error. Please check your connection and try again.");
}
}
if (status === "sent") {
return <p>Thanks, your message has been sent.</p>;
}
return (
<form onSubmit={handleSubmit}>
<label>
Your name <input type="text" name="name" required />
</label>
<label>
Your email <input type="email" name="email" required />
</label>
<label>
Message <textarea name="message" required />
</label>
{/* Honeypot: hidden and always empty. Bots fill it in and are
flagged as spam; the endpoint answers them like a success. */}
<input type="text" name="_honey" style={{ display: "none" }} tabIndex={-1} autoComplete="off" />
<button type="submit" disabled={status === "sending"}>
{status === "sending" ? "Sending..." : "Send"}
</button>
{status === "error" && <p role="alert">{error}</p>}
</form>
);
}The page that uses it stays a Server Component. The client boundary is the form component alone, the smallest possible surface:
// app/contact/page.tsx, still a Server Component.
import ContactForm from "./ContactForm";
export default function ContactPage() {
return (
<main>
<h1>Contact us</h1>
<ContactForm />
</main>
);
}The component is the tested pattern from the React guide, kept identical on the wire: preventDefault() stops the browser's own submission, FormData collects the fields by name, and Accept: application/json is the header that makes the endpoint answer with JSON instead of navigating. One deliberate difference from Option 1: _redirect plays no role here. In fetch mode your code owns what happens next, and the "sent" state above is that confirmation. A lead or quote form is the same component with more named fields.
Do you need an API route for this? No
A Route Handler that receives a form and forwards it to Form Backend adds a hop that you then own: your code has to read the fields, re-send them, map the errors back, and be up whenever the form is used, in exchange for nothing the direct options lack. The endpoint accepts the browser's request directly, including from another domain, because it answers with Access-Control-Allow-Origin: *; no proxy is needed to satisfy CORS, and a native form post is a navigation, which CORS does not govern at all.
Server Actions belong to the same category. They are the right tool when the submission must run your own server logic in your own app. For a form whose entire job is "deliver these fields to the site owner", that logic already exists, hosted: validation, spam filtering, storage, a dashboard and email delivery, as the static websites guide also lays out for the no-framework case. Keep your Next.js server for the work that is actually yours.
Responses and errors
- Native mode: success is the 303 redirect to your
_redirectpage or the built-in thank-you page. An error (a paused form, an oversized submission) answers with its status code and a short message from the endpoint instead of your page. - Fetch mode: success is HTTP 200 with
{"ok": true, "message": "Submission received."}; failure is a status code with{"ok": false, "error": "..."}, for example 404"Form not found."for a wrong key or 429 when the rate limit of 10 submissions per 60 seconds per form per visitor is hit. The component above displays that message, so most mistakes explain themselves during development. - Both modes: a submission caught by the honeypot answers like a success and is flagged as spam in your dashboard, never emailed. The full status table is the docs error reference, and the field contract is the docs example.
Troubleshooting, Next.js edition
- The form never reaches the endpoint. Check what
actionactually holds. A function there makes it a Server Action on your own server; the direct integration needs the endpoint URL as a string. - Build error about hooks or event handlers in a Server Component. The fetch component uses
useStateandonSubmit, so it needs"use client"at the top of its own file. The native form needs no such line; adding it there is not an error, just a larger client bundle than necessary. - The page navigates even in fetch mode.
preventDefault()is missing, so the browser performs its default submission alongside yours. - fetch receives an HTML page instead of JSON. The
Accept: application/jsonheader is missing; without it the endpoint uses the browser-form behaviour. - Fields arrive missing. An input without a
nameis not submitted, in either mode. - 404, 405, 429. A wrong key in the endpoint URL (404); a form whose
methodis missing and therefore defaults to GET (405); and the rate limit while testing (429), which clears within a minute.
Limits
- File uploads are not stored. Only text values are kept, whichever mode submits them.
- No outgoing webhooks and no autoresponder email to the visitor. Confirmation is your thank-you page or your client-side state.
- At most 25 KB per submission, 50 fields, names up to 100 characters, values up to 5,000 characters.
- Rate limit of 10 submissions per 60 seconds per form per visitor; monthly quotas per form of 250 on Free, 2,500 on Pro ($12/month) and 10,000 on Agency ($35/month).
The authoritative list is the docs limits section with the not supported list. Anything else, the support page reaches the founder directly.