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.

Point your Next.js form somewhere.

FormRoute is live in private access. Drop your email and we'll send your invite.

Free forever · 1,000 submissions/mo · unlimited forms