Comparison

Next.js contact form template

Three ways to send a contact form from the Next.js App Router, each a complete set of files. Pick by where you want the request to Formgong to come from: the visitor's browser, a route handler, or a Server Action.

Short answer: For most sites the client component is enough: paste app/contact/ContactForm.tsx and page.tsx and set your key. Use the route handler or the Server Action when you want the key in an environment variable instead of the page.

Which one to use

The client version posts straight from the browser. Formgong sees the visitor's IP address, so its per-address flood limits and spam checks work as intended. The key is visible in the page, which is fine because it can only send submissions.

The route handler and the Server Action post from your server, and the key lives in FORMGONG_ACCESS_KEY. The cost is that Formgong sees your server's address for every message, so all visitors share one flood limit. That is rarely a problem for a contact form. For a busy signup form, post from the browser.

Option 1: client component

A client component that posts with fetch. If you set NEXT_PUBLIC_FORMGONG_KEY, it takes the key from there; otherwise replace the placeholder.

app/contact/ContactForm.tsx
"use client";

import { useState, type FormEvent } from "react";

const ACCESS_KEY = process.env.NEXT_PUBLIC_FORMGONG_KEY ?? "fk_your_access_key";

type Status = "idle" | "sending" | "sent" | "error";

export default function ContactForm() {
  const [status, setStatus] = useState<Status>("idle");
  const [notice, setNotice] = useState("");

  async function handleSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const form = event.currentTarget;
    const data = new FormData(form);
    data.set("access_key", ACCESS_KEY);
    setStatus("sending");
    setNotice("");
    try {
      const response = await fetch("https://formgong.com/submit", {
        method: "POST",
        headers: { Accept: "application/json" },
        body: data,
      });
      const result: { success: boolean; message?: string } = await response.json();
      if (result.success === true) {
        form.reset();
        setStatus("sent");
        setNotice("Thanks! Your message has been sent.");
      } else {
        setStatus("error");
        setNotice(result.message || "Not sent. Please try again.");
      }
    } catch {
      setStatus("error");
      setNotice("Not sent: check your connection and try again.");
    }
  }

  return (
    <form onSubmit={handleSubmit} className="contact-form">
      <label>
        Name
        <input type="text" name="name" autoComplete="name" required />
      </label>
      <label>
        Email
        <input type="email" name="email" autoComplete="email" required />
      </label>
      <label>
        Message
        <textarea name="message" rows={5} required />
      </label>
      {/* Honeypot: people never see it, simple bots fill it in. */}
      <div aria-hidden="true" style={{ position: "absolute", width: 1, height: 1, overflow: "hidden", clipPath: "inset(50%)" }}>
        <input type="text" name="botcheck" tabIndex={-1} autoComplete="off" />
      </div>
      <button type="submit" disabled={status === "sending"}>
        {status === "sending" ? "Sending…" : "Send message"}
      </button>
      <p role={status === "error" ? "alert" : "status"}>{notice}</p>
    </form>
  );
}

The page that renders it

The same page.tsx works for all three options. It stays a server component; only the form is a client component.

app/contact/page.tsx
import ContactForm from "./ContactForm";

export const metadata = { title: "Contact" };

export default function ContactPage() {
  return (
    <main>
      <h1>Contact us</h1>
      <ContactForm />
    </main>
  );
}

Option 2: route handler

The handler adds the key from the environment and passes Formgong's answer through, so the form still learns whether the message was accepted. Put FORMGONG_ACCESS_KEY=fk_... in .env.local and in your host's environment variables.

app/api/contact/route.ts
// Forwards the form to Formgong from your server, so the access key stays in an env variable.
const FORMGONG_URL = "https://formgong.com/submit";

export async function POST(request: Request) {
  const key = process.env.FORMGONG_ACCESS_KEY;
  if (!key) {
    return Response.json({ success: false, message: "FORMGONG_ACCESS_KEY is not set." }, { status: 500 });
  }

  const data = await request.formData();
  data.set("access_key", key);

  let response: Response;
  try {
    response = await fetch(FORMGONG_URL, {
      method: "POST",
      headers: { Accept: "application/json" },
      body: data,
    });
  } catch {
    return Response.json({ success: false, message: "The form service could not be reached." }, { status: 502 });
  }
  // Pass Formgong's answer through: success: true only when it accepted the message.
  const result = await response.json().catch(() => ({ success: false, message: "Unexpected answer from the form service." }));
  return Response.json(result, { status: response.ok ? 200 : response.status });
}

The form for the route handler

It posts to /api/contact and never sees the key.

app/contact/ContactForm.tsx
"use client";

import { useState, type FormEvent } from "react";

type Status = "idle" | "sending" | "sent" | "error";

export default function ContactForm() {
  const [status, setStatus] = useState<Status>("idle");
  const [notice, setNotice] = useState("");

  async function handleSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    const form = event.currentTarget;
    setStatus("sending");
    setNotice("");
    try {
      const response = await fetch("/api/contact", { method: "POST", body: new FormData(form) });
      const result: { success: boolean; message?: string } = await response.json();
      if (result.success === true) {
        form.reset();
        setStatus("sent");
        setNotice("Thanks! Your message has been sent.");
      } else {
        setStatus("error");
        setNotice(result.message || "Not sent. Please try again.");
      }
    } catch {
      setStatus("error");
      setNotice("Not sent: check your connection and try again.");
    }
  }

  return (
    <form onSubmit={handleSubmit} className="contact-form">
      <label>
        Name
        <input type="text" name="name" autoComplete="name" required />
      </label>
      <label>
        Email
        <input type="email" name="email" autoComplete="email" required />
      </label>
      <label>
        Message
        <textarea name="message" rows={5} required />
      </label>
      {/* Honeypot: people never see it, simple bots fill it in. */}
      <div aria-hidden="true" style={{ position: "absolute", width: 1, height: 1, overflow: "hidden", clipPath: "inset(50%)" }}>
        <input type="text" name="botcheck" tabIndex={-1} autoComplete="off" />
      </div>
      <button type="submit" disabled={status === "sending"}>
        {status === "sending" ? "Sending…" : "Send message"}
      </button>
      <p role={status === "error" ? "alert" : "status"}>{notice}</p>
    </form>
  );
}

Option 3: Server Action

The action runs on the server, sends the form to Formgong and returns a small state object. It reads the same FORMGONG_ACCESS_KEY.

app/contact/actions.ts
"use server";

export type ContactState = { status: "idle" | "sent" | "error"; message: string };

export async function sendContact(_previous: ContactState, data: FormData): Promise<ContactState> {
  const key = process.env.FORMGONG_ACCESS_KEY;
  if (!key) return { status: "error", message: "FORMGONG_ACCESS_KEY is not set." };
  data.set("access_key", key);
  try {
    const response = await fetch("https://formgong.com/submit", {
      method: "POST",
      headers: { Accept: "application/json" },
      body: data,
    });
    const result: { success: boolean; message?: string } = await response.json();
    if (result.success === true) return { status: "sent", message: "Thanks! Your message has been sent." };
    return { status: "error", message: result.message || "Not sent. Please try again." };
  } catch {
    return { status: "error", message: "The form service could not be reached. Please try again." };
  }
}

The form for the Server Action

useActionState gives the form the action's result and a pending flag. The wrapper around the action matters: if the visitor goes offline, calling a Server Action throws, and without the try the form would just stop responding.

app/contact/ContactForm.tsx
"use client";

import { useActionState } from "react";
import { sendContact, type ContactState } from "./actions";

const initial: ContactState = { status: "idle", message: "" };

// If the browser cannot reach your server, the action call itself throws: say so instead of crashing.
async function submit(previous: ContactState, data: FormData): Promise<ContactState> {
  try {
    return await sendContact(previous, data);
  } catch {
    return { status: "error", message: "Not sent: check your connection and try again." };
  }
}

export default function ContactForm() {
  const [state, formAction, pending] = useActionState(submit, initial);

  return (
    <form action={formAction} className="contact-form">
      <label>
        Name
        <input type="text" name="name" autoComplete="name" required />
      </label>
      <label>
        Email
        <input type="email" name="email" autoComplete="email" required />
      </label>
      <label>
        Message
        <textarea name="message" rows={5} required />
      </label>
      {/* Honeypot: people never see it, simple bots fill it in. */}
      <div aria-hidden="true" style={{ position: "absolute", width: 1, height: 1, overflow: "hidden", clipPath: "inset(50%)" }}>
        <input type="text" name="botcheck" tabIndex={-1} autoComplete="off" />
      </div>
      <button type="submit" disabled={pending}>
        {pending ? "Sending…" : "Send message"}
      </button>
      <p role={state.status === "error" ? "alert" : "status"}>{state.message}</p>
    </form>
  );
}

How we tested this code

We pasted each snippet unchanged into a fresh project (Next.js 16.4 (App Router) and React 19.3), filled in a real access key, and submitted it in Chromium three times against a local copy of Formgong. With the right key the form showed its success text and exactly one submission was stored, not marked as spam. With a key Formgong does not know, the form showed Formgong's own error, "That access key does not exist.", and nothing was stored. With the request cut off, the form said it was not sent. None of the snippets says "sent" before Formgong answers success: true.

For the route handler and the Server Action, the wrong-key test ran on a second next start with a wrong FORMGONG_ACCESS_KEY. In our first run the Server Action form showed nothing when the request was cut off; the try wrapper above is the fix, and the run after it passed.

Questions

Is it safe to put the access key in front-end code?

Yes. The key only lets someone send a submission to your form, the same thing your page already does. It cannot read submissions or change settings. If you want it out of the page source anyway, add your domain under allowed domains in the form settings, or post through your own server.

Does this work with the Pages Router?

The client component does, without the "use client" line. The route handler becomes pages/api/contact.ts with req and res, and Server Actions are App Router only.

Do I need Resend or Nodemailer?

No. Formgong sends the notification email, so there is no SMTP or email API key in your project.

What is the hidden botcheck field?

A honeypot. It sits off screen, so people never fill it, while simple bots fill every field they find. Formgong marks a submission with it filled in as spam, so it does not reach your email. Keep the name botcheck.

Sources

Checked on 16.10.2026 against these public pages: Next.js: Route Handlers, Next.js: Forms and Server Actions, React: useActionState.

← Formgong