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?
| Scenario | Use |
|---|---|
Static-exported Next.js (output: "export") | Client-side fetch |
| Vercel / hosted Next.js, want progressive enhancement | Server Actions |
| Need inline success message without redirect | Server Actions + useFormState |
| Custom server-side logic before sending | API 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.