Keep the boundary small
Use a Client Component for event handlers and status state. There is no need to move the rest of your page into the client bundle.
Form Koi for Next.js
Add a contact form to the Next.js App Router with one small Client Component. The browser posts to Form Koi, so your page can stay a Server Component and the form works with static export.
250 submissions/month free. No credit card required.
The App Router example below starts with use client because it uses state and submit handlers. Your page can remain a Server Component and import this form. The request goes directly to your public Form Koi endpoint.
Use a Client Component for event handlers and status state. There is no need to move the rest of your page into the client bundle.
The browser posts to Form Koi in either case. This particular flow does not depend on a Server Action or a Next.js route handler.
Your form key belongs in browser code. Management API tokens and Turnstile secret keys stay out of your component.
ExploreCopy, connect, send
It will not deliver messages until connected to your form. It starts without Turnstile or file uploads. When you enable either, copy the updated example from your workspace.
"use client";
import { useRef, useState } from "react";
export default function ContactForm() {
const [status, setStatus] = useState("");
const [busy, setBusy] = useState(false);
const request = useRef(null);
const submitting = useRef(false);
async function submit(event) {
event.preventDefault();
if (submitting.current) return;
const form = event.currentTarget;
const body = new FormData(form);
submitting.current = true;
setBusy(true);
setStatus("");
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 20000);
try {
const fingerprint = JSON.stringify([...body.entries()].filter(([name]) => name !== "cf-turnstile-response"));
if (request.current?.fingerprint !== fingerprint) {
request.current = { fingerprint, id: crypto.randomUUID() };
}
const response = await fetch("https://api.formkoi.com/f/YOUR_PUBLIC_FORM_KEY", {
method: "POST",
headers: { "Idempotency-Key": request.current.id },
body,
signal: controller.signal,
});
const result = await response.json().catch(() => null);
if (!response.ok || result?.success !== true) {
throw new Error(result?.error?.message || "We could not confirm receipt. Your details are still here. Please try again.");
}
setStatus("Thanks! Your message has been received.");
form.reset();
request.current = null;
} catch (error) {
setStatus(controller.signal.aborted
? "This is taking longer than expected. We could not confirm receipt. Your details are still here; please try again."
: error instanceof Error && !(error instanceof TypeError)
? error.message
: "We could not complete the request. Your details are still here. Please try again.");
} finally {
clearTimeout(timeout);
submitting.current = false;
setBusy(false);
}
}
return (
<form onSubmit={submit}>
<fieldset disabled={busy} aria-label="Contact details" style={{ border: 0, margin: 0, padding: 0 }}>
<label>Your name <input name="name" autoComplete="name" required /></label>
<label>Email address <input name="email" type="email" autoComplete="email" required /></label>
<label>Your message <textarea name="message" required /></label>
<input name="botcheck" type="text" tabIndex={-1} autoComplete="off" style={{ display: "none" }} hidden />
</fieldset>
<button disabled={busy}>{busy ? "Sending…" : "Send message"}</button>
<p role="status">{status}</p>
</form>
);
}import ContactForm from "../components/ContactForm";
export default function ContactPage() {
return (
<main>
<h1>Contact us</h1>
<ContactForm />
</main>
);
}Style the fields to fit your website. For a complete HTML document with CSS, explore the form templates.
Before you publish
Test required fields and a real submission from your deployed domain. Configure allowed origins and your native thank-you destination.
ExploreFind the message and check Spam if it is missing. Confirm the chosen recipients and notification history; an accepted submission is not a delivery receipt.
ExploreThe allowance is shared across your workspace. At the monthly limit the API returns 429; provide a useful alternative way to contact you.
ExploreFrequently asked questions
No. This example uses a client-side POST and works without a Server Action. If your application needs private server-side processing, design that separately using the API contract.
Yes. The form submits from the browser to Form Koi, so the shown integration does not need a Next.js server at runtime. Test your deployed origin and protection settings.
Save it as app/components/ContactForm.jsx, then import it into app/contact/page.jsx. Keep the use client directive at the top of the form component. The accompanying page example is a Server Component.
Start with Form Koi Free