Build a copy-pasteable Next.js form with the App Router. No backend to babysit - just POST to Formigo and get submissions in your inbox, Slack, or anywhere else.
A Next.js form needs somewhere to send its data. Instead of wiring up your own API route, a database, and an email sender, you point the form at a Formigo endpoint and you are done. Formigo handles storage, spam filtering, and delivery to whatever channels you have configured.
Your endpoint
Every form gets its own URL like
https://formigo.io/f/your-form. It accepts both JSON and
form-encoded bodies, is CORS-enabled, and returns a 2xx on
success. If you get rate-limited it returns 429 with a
{ retry_after } payload so you know how long to wait.
This guide uses the modern App Router (Next.js 14 and 15). We show three idiomatic approaches - pick the one that fits your app:
fetch. Simplest, fully interactive, great for a contact form. Works on any App Router version.useActionState, so it needs Next.js 15.
Two spam fields show up in every example: a hidden honeypot
_formigo_hp (real humans leave it empty) and a timestamp
_formigo_t (bots tend to submit suspiciously fast). Formigo uses both
to silently drop junk.
A "use client" component using fetch, with success, error, and 429 handling
Drop this in app/contact/ContactForm.tsx and render it from any page
or layout. It is a single self-contained Client Component - state, validation
feedback, and the network call all live here. Replace your-form with
your real form slug.
"use client";
import { useEffect, useState } from "react";
const FORMIGO_ENDPOINT = "https://formigo.io/f/your-form";
type Status = "idle" | "loading" | "success" | "error" | "rate-limited";
export default function ContactForm() {
const [status, setStatus] = useState<Status>("idle");
const [message, setMessage] = useState("");
const [startedAt, setStartedAt] = useState(0);
// Stamp the time the form was rendered. Bots that submit
// instantly get flagged by Formigo.
useEffect(() => {
setStartedAt(Math.floor(Date.now() / 1000));
}, []);
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setStatus("loading");
setMessage("");
const form = e.currentTarget;
const data = Object.fromEntries(new FormData(form));
try {
const res = await fetch(FORMIGO_ENDPOINT, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...data,
_formigo_t: startedAt, // timestamp
// _formigo_hp is already included via the hidden input
}),
});
if (res.ok) {
setStatus("success");
setMessage("Thanks! Your message is on its way.");
form.reset();
setStartedAt(Math.floor(Date.now() / 1000));
} else if (res.status === 429) {
const body = await res.json();
setStatus("rate-limited");
setMessage(`Too many requests. Try again in ${body.retry_after}s.`);
} else {
setStatus("error");
setMessage("Something went wrong. Please try again.");
}
} catch {
setStatus("error");
setMessage("Network error. Please try again.");
}
}
const loading = status === "loading";
return (
<form onSubmit={handleSubmit} className="max-w-md space-y-4">
<div>
<label htmlFor="name" className="block mb-2">Name</label>
<input
id="name"
name="name"
type="text"
required
className="w-full px-4 py-2 border rounded-lg"
/>
</div>
<div>
<label htmlFor="email" className="block mb-2">Email</label>
<input
id="email"
name="email"
type="email"
required
className="w-full px-4 py-2 border rounded-lg"
/>
</div>
<div>
<label htmlFor="message" className="block mb-2">Message</label>
<textarea
id="message"
name="message"
rows={5}
required
className="w-full px-4 py-2 border rounded-lg"
/>
</div>
{/* Honeypot - hidden from humans, tempting for bots */}
<input
type="text"
name="_formigo_hp"
defaultValue=""
tabIndex={-1}
autoComplete="off"
aria-hidden="true"
style={{ position: "absolute", left: "-9999px" }}
/>
<button
type="submit"
disabled={loading}
className="px-6 py-3 rounded-lg bg-pink-500 text-white
hover:bg-pink-600 disabled:opacity-50"
>
{loading ? "Sending..." : "Send message"}
</button>
{status === "success" && (
<p role="status" className="text-green-600">{message}</p>
)}
{(status === "error" || status === "rate-limited") && (
<p role="alert" className="text-red-600">{message}</p>
)}
</form>
);
}
That is the whole integration. Because Formigo is CORS-enabled you can post
directly from the browser - no proxy required. If you prefer plain JavaScript,
rename the file to .jsx and drop the type annotations.
Forwards to Formigo server-side - works even without JavaScript
Prefer to keep submission logic on the server? A
Server Action lets the form post without a client-side
fetch. The action runs on the server, forwards the data to Formigo,
and the form degrades gracefully when JavaScript is disabled. Put the action in
app/contact/actions.ts:
"use server";
const FORMIGO_ENDPOINT = "https://formigo.io/f/your-form";
export type FormState = { ok: boolean; message: string };
export async function submitContact(
_prev: FormState,
formData: FormData
): Promise<FormState> {
// Honeypot caught a bot? Pretend everything is fine.
if (formData.get("_formigo_hp")) {
return { ok: true, message: "Thanks! Your message is on its way." };
}
const res = await fetch(FORMIGO_ENDPOINT, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: formData.get("name"),
email: formData.get("email"),
message: formData.get("message"),
_formigo_t: Number(formData.get("_formigo_t")),
}),
});
if (res.ok) {
return { ok: true, message: "Thanks! Your message is on its way." };
}
if (res.status === 429) {
const body = await res.json();
return { ok: false, message: `Too many requests. Try again in ${body.retry_after}s.` };
}
return { ok: false, message: "Something went wrong. Please try again." };
}
Then wire the action into a Client Component with
useActionState so you can show pending and result states. Save this
as app/contact/ContactForm.tsx:
"use client";
import { useActionState } from "react";
import { submitContact, type FormState } from "./actions";
const initialState: FormState = { ok: false, message: "" };
export default function ContactForm() {
const [state, formAction, pending] = useActionState(
submitContact,
initialState
);
return (
<form action={formAction} className="max-w-md space-y-4">
<input
name="_formigo_t"
type="hidden"
defaultValue={Math.floor(Date.now() / 1000)}
/>
<div>
<label htmlFor="name" className="block mb-2">Name</label>
<input id="name" name="name" required
className="w-full px-4 py-2 border rounded-lg" />
</div>
<div>
<label htmlFor="email" className="block mb-2">Email</label>
<input id="email" name="email" type="email" required
className="w-full px-4 py-2 border rounded-lg" />
</div>
<div>
<label htmlFor="message" className="block mb-2">Message</label>
<textarea id="message" name="message" rows={5} required
className="w-full px-4 py-2 border rounded-lg" />
</div>
{/* Honeypot */}
<input type="text" name="_formigo_hp" tabIndex={-1}
autoComplete="off" aria-hidden="true"
style={{ position: "absolute", left: "-9999px" }} />
<button type="submit" disabled={pending}
className="px-6 py-3 rounded-lg bg-pink-500 text-white
hover:bg-pink-600 disabled:opacity-50">
{pending ? "Sending..." : "Send message"}
</button>
{state.message && (
<p role={state.ok ? "status" : "alert"}
className={state.ok ? "text-green-600" : "text-red-600"}>
{state.message}
</p>
)}
</form>
);
}
Optional - add server-side validation before passing to Formigo
If you want your own API surface - to run extra validation, enrich the payload, or
keep the endpoint out of client bundles - add a Route Handler at
app/api/contact/route.ts that forwards to Formigo. Your Client
Component then posts to /api/contact instead of posting to Formigo
directly.
import { NextRequest, NextResponse } from "next/server";
const FORMIGO_ENDPOINT = "https://formigo.io/f/your-form";
export async function POST(request: NextRequest) {
const data = await request.json();
// Drop honeypot hits early.
if (data._formigo_hp) {
return NextResponse.json({ ok: true });
}
// Add any server-side validation you like here.
if (!data.email || !data.message) {
return NextResponse.json(
{ error: "Email and message are required." },
{ status: 422 }
);
}
const res = await fetch(FORMIGO_ENDPOINT, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (res.ok) {
return NextResponse.json({ ok: true });
}
if (res.status === 429) {
const body = await res.json();
return NextResponse.json(
{ error: "rate_limited", retry_after: body.retry_after },
{ status: 429 }
);
}
return NextResponse.json(
{ error: "submission_failed" },
{ status: 502 }
);
}
With this in place, change FORMIGO_ENDPOINT in the Client Component
above to "/api/contact". Everything else stays the same - same JSON
body, same 429 handling.
Grab a free Formigo endpoint, paste it into the code above, and start receiving submissions today. Free forever, no credit card required.