Next.js
Untested: Not yet tested on a live site. The steps follow how the form address works today; if one differs, tell us.
Two ways to send a Next.js contact form to FormParcel: a plain form with no client JavaScript, or a client component that calls fetch and reads the answer.
Before you start
You need a form address from the dashboard. It looks like https://f.formparcel.live/f/f_k7QmXp2aVb9sTn4cLw8RhE, and your own ID replaces the last part. If you have not made one yet, start with the quickstart. This guide is for the App Router.
Option 1: a plain form
A Server Component can render a normal HTML form that posts straight to FormParcel. No React code runs in the browser, so it also works before the page has hydrated. The visitor is sent to the page in _next afterwards. The one <script> tag is FormParcel’s bot check: it finds the form and adds its proof. React places it in the page head.
Example form address. Use yours from the dashboard.
// app/contact/page.tsx: a Server Component. No React code runs in the browser, so the form also works before hydration.
// The bot check is one small plain script (no cookies, no widget). Without it, submissions wait in your Spam folder.
export default function ContactPage() {
return (
<form action="https://f.formparcel.live/f/f_k7QmXp2aVb9sTn4cLw8RhE" method="POST">
<script src="https://f.formparcel.live/f/formparcel.js" async />
<div aria-hidden="true" style={{ position: "absolute", left: "-10000px" }}>
<input type="text" name="_gotcha" tabIndex={-1} autoComplete="off" />
</div>
<input type="hidden" name="_next" value="https://your-site.example/thanks" />
<label htmlFor="name">Name</label>
<input id="name" name="name" required />
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" required />
<label htmlFor="message">Message</label>
<textarea id="message" name="message" required />
<button type="submit">Send</button>
</form>
);
}_nextis a FormParcel field. It has nothing to do with Next.js. Its value must be a page on the client’s own site, and that site’s domain must be set in FormParcel. The trust rule is on special fields.- Without
_next, the visitor sees a short FormParcel confirmation page. - Without the bot check script, the form still posts, but each lead waits in your Spam folder. See Spam protection.
Option 2: a client component with fetch
Use this when you want to keep the visitor on the page and show your own message. It sends JSON and reads the answer.
"use client";
import Script from "next/script";
import { useState, type FormEvent } from "react";
const ENDPOINT = "https://f.formparcel.live/f/f_k7QmXp2aVb9sTn4cLw8RhE";
// FormParcel's bot check: no cookies, no widget. Without it, submissions wait in your Spam folder.
const BOT_CHECK_SCRIPT = "https://f.formparcel.live/f/formparcel.js";
declare global {
interface Window {
FormParcel?: { proof: (endpoint: string) => Promise<string> };
}
}
export function ContactForm() {
const [status, setStatus] = useState<"idle" | "sending" | "sent" | "error">("idle");
async function onSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setStatus("sending");
const data = Object.fromEntries(new FormData(event.currentTarget));
try {
// One proof per submission. The bot check may still be loading on a slow phone, so wait for it (10 seconds at most). If it cannot load, the lead is still sent and waits in your Spam folder.
const fp = window.FormParcel ?? (await new Promise<Window["FormParcel"]>((ok) => { addEventListener("formparcel:ready", () => ok(window.FormParcel), { once: true }); setTimeout(() => ok(undefined), 10000); }));
const _fp_proof = await fp?.proof(ENDPOINT).catch(() => undefined);
const response = await fetch(ENDPOINT, {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({ ...data, _fp_proof }),
});
// 204: the post was dropped as spam (the trap field was filled). Nothing was saved.
const result = response.status === 204 ? null : await response.json();
setStatus(result?.accepted ? "sent" : "error");
} catch {
setStatus("error");
}
}
if (status === "sent") return <p>Thanks, we got your message.</p>;
return (
<form onSubmit={onSubmit}>
<Script src={BOT_CHECK_SCRIPT} strategy="afterInteractive" />
{/* Spam trap: keep it hidden from people. */}
<div aria-hidden="true" style={{ position: "absolute", left: "-10000px" }}>
<input type="text" name="_gotcha" tabIndex={-1} autoComplete="off" />
</div>
<label htmlFor="name">Name</label>
<input id="name" name="name" required />
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email" required />
<label htmlFor="message">Message</label>
<textarea id="message" name="message" required />
<button type="submit" disabled={status === "sending"}>
{status === "sending" ? "Sending…" : "Send"}
</button>
{status === "error" ? <p role="alert">Your message was not sent. Please try again.</p> : null}
</form>
);
}- The component loads FormParcel’s bot check with
next/scriptand askswindow.FormParcel.proof()for one proof per send, sent as_fp_proof. Without a valid proof the lead waits in your Spam folder. - You do not need a route handler or a proxy. FormParcel answers the browser’s preflight request, so
fetchworks from your site. - If you set allowed origins on the form, add your site’s host, and
localhostwhile you test locally. A request from any other origin is refused (origin not allowed). - An accepted lead returns
accepted: true. AdeliveryStatusofpendingstill means success: do not send it again. Every status is on the fetch guide. - A 204 means the post was dropped as spam because the trap field was filled in. Nothing was saved, so the example shows an error. A proof sent twice is not dropped: that lead is held in your Spam folder and the answer is the usual 200.
- If the site sends a Content-Security-Policy, allow the form endpoint host in
script-srcandconnect-src.
curl, and every post from a page that does not load the script. The dashboard then shows Bot check not installed under Needs action. Fix: load the script on the page and send the proof from FormParcel.proof() with each fetch. Learn moreKeep the address in configuration
The address is public by design: it ends up in the page. Anyone who has it can post to it, which is why the bot check, the spam trap and rate limits exist. Treat it as an address, not a secret. A NEXT_PUBLIC_ environment variable is a fine place to keep it, so every environment can use the same value.
Check it
Submit the form once, then open the form in the dashboard and look at its receipt under Leads. If nothing arrives, go through Troubleshooting.