Next.js contact form without an API route
The shortest working Next.js contact form never touches your server:
export default function ContactPage() {
return (
<form action="https://api.formroute.dev/f/site_xyz" method="POST">
<input name="email" type="email" required />
<textarea name="message" required />
<button>Send</button>
</form>
);
}
That is a Server Component rendering a plain HTML form. No "use client", no hooks, no JavaScript sent to the browser, and no app/api/contact/route.ts in your codebase. It works in the App Router, the Pages Router, and a static export.
What an API route actually buys you
The reflex is to add a route handler, POST to it from the client, and forward the message on. It is worth being precise about what that hop is for, because most of the time the answer is nothing:
| Reason people add a route | Does it hold up |
|---|---|
| “To hide the endpoint” | No. A form endpoint is public by design: it sits in the HTML of every page that carries the form |
| “To validate” | Only if the endpoint cannot. A good one validates server-side already |
| “To keep an API key secret” | Yes, if your provider needs a secret key. Form endpoints usually do not |
| “To transform the payload” | Yes. This is the real reason, and it is rarer than it looks |
Every route you add is code you own: the errors, the timeouts, the spam, the cold starts. Skipping it is one less thing in the deploy.
The Server Action version
If the request has to leave from your infrastructure, a Server Action gets you there without a route file:
export default function ContactPage() {
async function send(formData: FormData) {
"use server";
await fetch("https://api.formroute.dev/f/site_xyz", {
method: "POST",
body: formData,
});
}
return (
<form action={send}>
<input name="email" type="email" required />
<textarea name="message" required />
<button>Send</button>
</form>
);
}
The action prop takes the function instead of a URL. Next.js wires up the POST, and the form still submits with JavaScript disabled, which an onSubmit handler does not.
Rendering the result without a navigation
This is where the signature changes, and it is the detail that breaks most copied examples. useActionState calls your action as (previousState, formData), not (formData). Reuse the function above unchanged and formData binds to the previous state, which is null on the first render.
The action moves to its own file and gains a leading parameter:
// app/contact/actions.ts
"use server";
export async function send(_previous: unknown, formData: FormData) {
const response = await fetch("https://api.formroute.dev/f/site_xyz", {
method: "POST",
body: formData,
});
return response.ok ? { ok: true } : { error: "Something went wrong. Try again." };
}
"use client";
import { useActionState } from "react";
import { send } from "./actions";
export function ContactForm() {
const [state, action, pending] = useActionState(send, null);
return (
<form action={action}>
<input name="email" type="email" required />
<textarea name="message" required />
<button disabled={pending}>{pending ? "Sending…" : "Send"}</button>
{state?.ok && <p>Thanks, we got it.</p>}
{state?.error && <p role="alert">{state.error}</p>}
</form>
);
}
useActionState is React 19, which means Next.js 15 and up. On React 18 and Next.js 14 the equivalent is useFormState, imported from react-dom rather than react, with the same (previousState, formData) signature. The hook is plain React, not Next.js wiring: the React contact form covers using it without a framework.
What static export takes away
This is the constraint that catches people. With output: "export" in next.config.js there is no server in the deployed output, so Server Actions and route handlers are not available. The build tells you, usually later than you would like.
On a static export the plain form at the top of this post is not the simple option. It is the only option, and validation has nowhere on your side to run.
Where validation has to hold
Whichever version you ship, the browser attributes are a convenience. required is gone the moment a request arrives from something that is not your form, and on a static export you have no server of your own to check anything.
The rules have to be enforced where the POST lands. On FormRoute they are declared once per form and applied to every request, including the ones that never rendered your page:
email required|email
message required|min:10
The endpoint returns the failure, which is what gives state.error above something specific to say.
Ship the version that matches your output mode
Pick by what your deploy actually contains. Running on a server: any of the three, and the Server Action is worth it only if you have a payload to transform. Static export: the plain form, always.
Either way the submission still has to reach a person, and that half of the problem is not in the component. What a form backend is covers what happens between the POST and your inbox. FormRoute works the same whether or not your output contains a server, which is the point. Public signups are not open yet, so get on the invite list.