Next.js Guide

Next.js Form to Email or API

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.

How it works

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:

  1. Client Component - submit straight from the browser with fetch. Simplest, fully interactive, great for a contact form. Works on any App Router version.
  2. Server Action - submit from a server function so your form works even without JavaScript and your logic stays server-side. Uses React 19's useActionState, so it needs Next.js 15.
  3. Route Handler proxy - forward submissions through your own API route if you want to add server-side validation or hide headers.

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.

Client Component form

Next.js contact form (recommended)

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.

Server Action variant

Submit with a Server Action

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>
  );
}

Route Handler proxy

Forward through a Route Handler

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.

Ship your Next.js form in minutes

Grab a free Formigo endpoint, paste it into the code above, and start receiving submissions today. Free forever, no credit card required.