Skip to content
form koi.Start free

Form Koi for Next.js

Next.js contact form
without an API route.

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.

Your form → Form Koi

From website form to email notification.

  1. 01 / Your websiteYour fields, styles, and public form endpoint.
  2. 02 / Submission inboxReview the message and its spam status.
  3. 03 / Notification historySee the recipient and follow delivery attempts.
See how delivery works ↗

One client component. No route handler needed.

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.

01

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.

02

Deploy static or server-rendered

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.

03

Use only the public endpoint

Your form key belongs in browser code. Management API tokens and Turnstile secret keys stay out of your component.

Explore

Copy, connect, send

Copy the Next.js contact form example.

  1. Create a form and verify its recipient in your Form Koi workspace.
  2. Replace the complete example endpoint with the URL from your connection guide.
  3. Keep the honeypot name in sync with your form settings, then submit from your website.
This example uses a placeholder endpoint.

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.

app/components/ContactForm.jsx
"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>
  );
}

app/contact/page.jsx
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 submission storage and email delivery.

01

Check the website

Test required fields and a real submission from your deployed domain. Configure allowed origins and your native thank-you destination.

Explore
02

Check the inbox

Find the message and check Spam if it is missing. Confirm the chosen recipients and notification history; an accepted submission is not a delivery receipt.

Explore
03

Check the limits

The allowance is shared across your workspace. At the monthly limit the API returns 429; provide a useful alternative way to contact you.

Explore

Frequently asked questions

Contact form
questions.

Do I need a Server Action?

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.

Does this work with a static export?

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.

Where should I put the example?

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

Start receiving
form submissions.

Create a free form Read the quickstart