How to Handle Form Submissions in Next.js
Handle form submissions in Next.js with Server Actions, Route Handlers, or a form backend — how each works and when to reach for which.
The ShipMyForm team
· 4 min read
A form in Next.js is easy to render and surprisingly involved to handle. The markup is a few lines. The part that takes real work is everything that happens after someone clicks submit: validating the input, filtering out bots, storing the entry somewhere durable, and getting an email into your inbox. The App Router gives you good tools for that — but for a contact or lead form, building and securing a backend to do it is often more than the job actually needs.
This guide covers the three realistic ways to handle form submissions in a modern App Router project — Server Actions, Route Handlers, and pointing the form at a form backend — with code for each, and a clear rule for picking between them.
Option 1: Server Actions
Server Actions are the App Router's built-in answer. You write an async function
marked "use server" and pass it straight to a form's action prop. React
serializes the submission, calls your function on the server, and — because it is
a real form submission — the whole thing works before any JavaScript loads.
// app/contact/page.tsx
export default function ContactPage() {
async function submit(formData: FormData) {
"use server";
const email = formData.get("email");
const message = formData.get("message");
// You own everything from here: validate the fields,
// check for spam, store the row, send the email...
await saveSubmission({ email, message });
await sendEmail({ email, message });
}
return (
<form action={submit}>
<input name="email" type="email" required />
<textarea name="message" required></textarea>
<button type="submit">Send</button>
</form>
);
}This is clean and colocated, and progressive enhancement comes for free. The
catch is what the comment glosses over. saveSubmission, sendEmail, the spam
check, the validation — those are yours to write and maintain. And a Server
Action runs on the server, so this only works on a deployment that runs Node.
It does not run on a statically exported site.
Server Actions execute on the server on every submit. A site built with
output: 'export' ships static HTML, CSS, and JS with nothing to
run them on, so the action never fires. If you deploy static files to a CDN or
a plain static host, this option is off the table.
Option 2: a Route Handler
If you would rather submit with fetch and handle the response in the browser,
a Route Handler gives you an HTTP endpoint. You create app/api/contact/route.ts
with a POST export, then post to it from a client component.
// app/api/contact/route.ts
export async function POST(request: Request) {
const { email, message } = await request.json();
// Same responsibilities as before, just at an endpoint:
// validate, filter spam, store, and send the email yourself.
await saveSubmission({ email, message });
await sendEmail({ email, message });
return Response.json({ ok: true });
}// app/contact/form.tsx
"use client";
import { useState } from "react";
export function ContactForm() {
const [sent, setSent] = useState(false);
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const data = Object.fromEntries(new FormData(e.currentTarget));
await fetch("/api/contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
setSent(true);
}
if (sent) return <p>Thanks — we’ll be in touch.</p>;
return (
<form onSubmit={onSubmit}>
<input name="email" type="email" required />
<textarea name="message" required></textarea>
<button type="submit">Send</button>
</form>
);
}This gives you full control over the request and response, which is handy when the client needs the result. But the trade-offs are the same as Server Actions: you still build validation, spam filtering, storage, and email yourself, and a Route Handler is server code, so it also cannot run on a statically exported site.
Option 3: point the form at a form backend
The third option is to not write server code at all. A form backend
is a hosted endpoint that receives the POST for you and handles validation, spam
filtering, storage, and email delivery. You point your form's action at it and
you are done. With ShipMyForm the endpoint looks like https://shipmyform.com/f/YOUR_FORM_ID.
Because it is just an HTML form posting to a URL, the plainest version needs no JavaScript and no server on your side at all:
<form action="https://shipmyform.com/f/YOUR_FORM_ID" method="POST">
<input name="email" type="email" required />
<textarea name="message" required></textarea>
<button type="submit">Send</button>
</form>Submitted this way, the browser does a normal POST and follows a 302 redirect
to a thank-you page. That works anywhere Next.js runs — including a fully static
export, where the other two options can't.
If you want an inline success state instead of a redirect, make the form a client
component and submit with fetch. Send an Accept: application/json header and
the endpoint replies with JSON instead of redirecting, so you can read the result
and update state:
// app/contact/form.tsx
"use client";
import { useState } from "react";
export function ContactForm() {
const [sent, setSent] = useState(false);
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
const res = await fetch("https://shipmyform.com/f/YOUR_FORM_ID", {
method: "POST",
headers: { Accept: "application/json" },
body: new FormData(e.currentTarget),
});
if (res.ok) setSent(true);
}
if (sent) return <p>Thanks — we’ll be in touch.</p>;
return (
<form onSubmit={onSubmit}>
<input name="email" type="email" required />
<textarea name="message" required></textarea>
<input name="_gotcha" type="text" tabIndex={-1} autoComplete="off" style={{ display: "none" }} />
<button type="submit">Send</button>
</form>
);
}Note that the fetch body is a FormData object, not JSON — no
Content-Type header needed, the browser sets it. A few field names are
reserved and shape how the submission is handled:
_replyto— sets the reply-to address on the notification email, so you can hit reply and answer the person directly._subject— sets the subject line of that email._gotcha— a honeypot. Leave it hidden and empty; bots that fill it in get their submission dropped.
ShipMyForm’s free plan includes 100 submissions a month, and the email and webhook connectors are free — enough to run a marketing or contact form without a card. Spam filtering and storage are handled for you, so there is no backend to secure.
How the three compare
| Approach | Needs a server? | You build validation/spam/storage? | Works with static export? | Setup |
|---|---|---|---|---|
| Server Actions | Yes | Yes | No | Write a "use server" function |
| Route Handler | Yes | Yes | No | Write app/api/.../route.ts + client fetch |
| Form backend | No | No | Yes | Point action at a URL |
The pattern is hard to miss. Server Actions and Route Handlers are the right call when you already run a Node server and want form logic living inside your app — and you accept that validation, spam, storage, and email are yours to build. The moment your site is statically exported, or you simply don't want to run and secure server code for a contact form, a form backend is the natural fit.
Next steps
- New to the idea? Read what a form backend actually is.
- See the React & Next.js integration for the client component and reserved fields in full.
- Worried about bots? Here’s how to stop form spam without reCAPTCHA.
- Start free — 100 submissions a month, email and webhook connectors on every plan, no card required.
Frequently asked questions
- Do I need an API route for a Next.js contact form?
- No. In the App Router you can post a form to a Server Action without writing an API route at all, and you can skip server code entirely by pointing the form's action at a form backend. A Route Handler is only one of several options, not a requirement.
- Can I use a form backend with a statically exported Next.js site?
- Yes, and it is the natural fit. A site built with output: 'export' ships pure static files and cannot run Server Actions or Route Handlers, so it has no way to process a POST itself. Pointing the form at a form backend like ShipMyForm gives you submission handling, spam filtering, storage, and email without any server.
- Server Actions or a form backend — which should I use?
- Use Server Actions when you already run a Node server and want form logic colocated with your app, and you are happy to build validation, spam filtering, storage, and email delivery yourself. Use a form backend when you want those handled for you, or when your site is statically exported and cannot run server code.
- How do I show a success message without a page reload?
- Make the form a client component and submit with fetch, sending an Accept: application/json header so the backend replies with JSON instead of redirecting. Read the response and flip a piece of state to render an inline success message. The plain HTML version works too, but it uses a full-page redirect rather than an inline update.
Related guides
What Is a Form Backend? (And When You Need One)
The plain-language definition, how form endpoints work, and when a hosted backend beats rolling your own.
Astro Contact Form Without a Backend (2026)
A copy-paste contact form for Astro that emails you on every submission — no server or API route.
How to Send an HTML Form to Your Email (Without a Backend)
Why mailto: and PHP mail() let you down on a static site — and the reliable way to get form submissions into your inbox, spam folder avoided.