Back to blog

    Next.js Contact Form (App Router, Server Actions & Client Fetch)

    Two complete approaches to building a contact form in Next.js — Server Actions for zero-JS forms and client-side fetch for full UX control. No custom API route needed.

    Next.js Contact Form (App Router, Server Actions & Client Fetch)

    Next.js gives you two distinct approaches to handling form submissions in the App Router: Server Actions (server-side, progressively enhanced) and client-side fetch (full control over UX). This guide shows both, with complete working examples.

    Approach 1: Client-Side Fetch (Full UX Control)

    The simplest approach for most Next.js apps. Handle the submit on the client, show loading and success states, never leave the page:

    1// app/contact/page.tsx
    2"use client";
    3
    4import { useState } from "react";
    5
    6export default function ContactPage() {
    7  const [status, setStatus] = useState<"idle" | "sending" | "sent" | "error">("idle");
    8
    9  async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    10    e.preventDefault();
    11    setStatus("sending");
    12
    13    const data = Object.fromEntries(new FormData(e.currentTarget));
    14
    15    try {
    16      const res = await fetch("https://formboost.app/f/YOUR_ENDPOINT_ID", {
    17        method: "POST",
    18        headers: { "Content-Type": "application/json" },
    19        body: JSON.stringify(data),
    20      });
    21
    22      if (!res.ok) throw new Error();
    23      setStatus("sent");
    24    } catch {
    25      setStatus("error");
    26    }
    27  }
    28
    29  if (status === "sent") {
    30    return (
    31      <main>
    32        <h1>Message sent</h1>
    33        <p>Thanks! We'll be in touch soon.</p>
    34      </main>
    35    );
    36  }
    37
    38  return (
    39    <main>
    40      <h1>Contact Us</h1>
    41      <form onSubmit={handleSubmit}>
    42        <label htmlFor="name">Name</label>
    43        <input id="name" name="name" type="text" required />
    44
    45        <label htmlFor="email">Email</label>
    46        <input id="email" name="email" type="email" required />
    47
    48        <label htmlFor="message">Message</label>
    49        <textarea id="message" name="message" rows={5} />
    50
    51        {status === "error" && <p>Something went wrong — please try again.</p>}
    52
    53        <button type="submit" disabled={status === "sending"}>
    54          {status === "sending" ? "Sending…" : "Send Message"}
    55        </button>
    56      </form>
    57    </main>
    58  );
    59}

    This is a Client Component ("use client") — it uses React state to track the submission lifecycle. Uses FormData to serialize the form, then converts to a plain object for JSON submission.

    Approach 2: Server Actions

    Server Actions let you handle form submissions server-side, with no custom API route. The form works even without JavaScript (progressive enhancement):

    1// app/contact/actions.ts
    2"use server";
    3
    4export async function submitContact(formData: FormData) {
    5  const data = {
    6    name: formData.get("name"),
    7    email: formData.get("email"),
    8    message: formData.get("message"),
    9  };
    10
    11  const res = await fetch("https://formboost.app/f/YOUR_ENDPOINT_ID", {
    12    method: "POST",
    13    headers: { "Content-Type": "application/json" },
    14    body: JSON.stringify(data),
    15  });
    16
    17  if (!res.ok) {
    18    throw new Error("Submission failed");
    19  }
    20}
    1// app/contact/page.tsx
    2import { submitContact } from "./actions";
    3import { redirect } from "next/navigation";
    4
    5export default function ContactPage() {
    6  async function action(formData: FormData) {
    7    "use server";
    8    await submitContact(formData);
    9    redirect("/thank-you");
    10  }
    11
    12  return (
    13    <main>
    14      <h1>Contact Us</h1>
    15      <form action={action}>
    16        <label htmlFor="name">Name</label>
    17        <input id="name" name="name" type="text" required />
    18
    19        <label htmlFor="email">Email</label>
    20        <input id="email" name="email" type="email" required />
    21
    22        <label htmlFor="message">Message</label>
    23        <textarea id="message" name="message" rows={5} />
    24
    25        <button type="submit">Send Message</button>
    26      </form>
    27    </main>
    28  );
    29}

    The server action runs on the server — your Formboost endpoint is called server-to-server, not from the browser. The redirect happens after successful submission.

    Approach 2b: Server Actions with useFormState (Client Feedback)

    To show inline success/error without a page redirect, combine Server Actions with useFormState from react-dom:

    1// app/contact/actions.ts
    2"use server";
    3
    4export async function submitContact(
    5  prevState: { status: string },
    6  formData: FormData
    7) {
    8  const data = {
    9    name: formData.get("name"),
    10    email: formData.get("email"),
    11    message: formData.get("message"),
    12  };
    13
    14  try {
    15    const res = await fetch("https://formboost.app/f/YOUR_ENDPOINT_ID", {
    16      method: "POST",
    17      headers: { "Content-Type": "application/json" },
    18      body: JSON.stringify(data),
    19    });
    20
    21    if (!res.ok) throw new Error();
    22    return { status: "success" };
    23  } catch {
    24    return { status: "error" };
    25  }
    26}
    1// app/contact/page.tsx
    2"use client";
    3
    4import { useFormState, useFormStatus } from "react-dom";
    5import { submitContact } from "./actions";
    6
    7function SubmitButton() {
    8  const { pending } = useFormStatus();
    9  return (
    10    <button type="submit" disabled={pending}>
    11      {pending ? "Sending…" : "Send Message"}
    12    </button>
    13  );
    14}
    15
    16export default function ContactPage() {
    17  const [state, action] = useFormState(submitContact, { status: "idle" });
    18
    19  if (state.status === "success") {
    20    return <p>Message sent! We'll be in touch.</p>;
    21  }
    22
    23  return (
    24    <form action={action}>
    25      <input name="name" type="text" required placeholder="Name" />
    26      <input name="email" type="email" required placeholder="Email" />
    27      <textarea name="message" rows={5} placeholder="Message" />
    28
    29      {state.status === "error" && <p>Something went wrong. Please try again.</p>}
    30
    31      <SubmitButton />
    32    </form>
    33  );
    34}

    useFormStatus tracks pending state inside the form — the SubmitButton component disables itself while the action is running.

    Approach 3: API Route (Pages Router or Manual Control)

    If you prefer a traditional API route:

    1// app/api/contact/route.ts
    2import { NextRequest, NextResponse } from "next/server";
    3
    4export async function POST(req: NextRequest) {
    5  const data = await req.json();
    6
    7  const res = await fetch("https://formboost.app/f/YOUR_ENDPOINT_ID", {
    8    method: "POST",
    9    headers: { "Content-Type": "application/json" },
    10    body: JSON.stringify(data),
    11  });
    12
    13  if (!res.ok) {
    14    return NextResponse.json({ error: "Submission failed" }, { status: 500 });
    15  }
    16
    17  return NextResponse.json({ ok: true });
    18}

    Then fetch /api/contact from your client component instead. This approach adds a round-trip but gives you a place to add server-side logic (rate limiting, validation, enrichment) before forwarding to Formboost.

    Which Approach to Use?

    ScenarioUse
    Static-exported Next.js (output: "export")Client-side fetch
    Vercel / hosted Next.js, want progressive enhancementServer Actions
    Need inline success message without redirectServer Actions + useFormState
    Custom server-side logic before sendingAPI route

    For most Next.js projects on Vercel, Server Actions are the cleanest path. For statically exported sites, use the client-side fetch approach since server-side code won't run.

    Getting Your Endpoint

    Sign up for Formboost — free tier, no credit card. Create an endpoint, copy the URL, and replace YOUR_ENDPOINT_ID in the examples above. Submissions show up in your dashboard with email notifications built in.

    Get started free →