All resources
Static site forms

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.

tsx
// 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 need a running server:

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.

ts
// 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 });
}
tsx
// 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&rsquo;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:

html
<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:

tsx
// 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&rsquo;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.
Free tier covers a real contact form:

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

ApproachNeeds a server?You build validation/spam/storage?Works with static export?Setup
Server ActionsYesYesNoWrite a "use server" function
Route HandlerYesYesNoWrite app/api/.../route.ts + client fetch
Form backendNoNoYesPoint 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

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