Log in

Next.js contact form

The App Router can submit a form on the server, which makes this the one React setup where a contact form keeps working with client JavaScript disabled. No API route, no client component, and no fetch in the browser at all.

The rendered result. The code below is the source — it is not run inside this preview.

Replace YOUR_FORM_ID with a form of your own and this sends to your inbox.

Create your form
app/contact/page.tsx
// app/contact/page.tsx — a Server Action, so the form works with
// client JavaScript disabled and the endpoint never reaches the browser.
import { redirect } from "next/navigation";

export default function ContactPage() {
  async function send(formData: FormData) {
    "use server";

    const res = await fetch("https://submit.formpost.ai/YOUR_FORM_ID", {
      method: "POST",
      headers: { Accept: "application/json" },
      body: formData,
    });

    // Without this the page just sits there on failure and the visitor
    // assumes it sent. Point these at pages you actually have.
    const { success } = await res.json();
    redirect(success ? "/contact/thanks" : "/contact/error");
  }

  return (
    <form action={send}>
      <h2>Contact us</h2>

      <label htmlFor="name">Name</label>
      <input id="name" name="name" required />

      <label htmlFor="email">Email</label>
      <input id="email" type="email" name="email" required />

      <label htmlFor="message">Message</label>
      <textarea id="message" name="message" rows={4} required />

      <button type="submit">Send message</button>
    </form>
  );
}
app/contact/contact.css
body {
  margin: 0;
  padding: 32px 16px;
  background: #fbfbfc;
  font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
  color: #18181b;
}

form {
  display: flex;
  flex-direction: column;
  max-width: 420px;
  margin: 0 auto;
}

h2 {
  margin: 0 0 18px;
  font-size: 20px;
}

label {
  margin-bottom: 6px;
  font-size: 13px;
  font-weight: 600;
  color: #3f3f46;
}

input,
textarea {
  box-sizing: border-box;
  font: inherit;
  margin-bottom: 16px;
  padding: 10px 12px;
  color: #18181b;
  background: #fff;
  border: 1px solid #d4d4d8;
  border-radius: 8px;
}

input:focus,
textarea:focus {
  border-color: #2563eb;
  outline: 2px solid rgba(37, 99, 235, 0.2);
}

textarea {
  resize: vertical;
}

button {
  padding: 11px 16px;
  font: inherit;
  font-weight: 600;
  color: #fff;
  background: #2563eb;
  border: 0;
  border-radius: 8px;
  cursor: pointer;
}

button:disabled {
  background: #93b4f5;
  cursor: default;
}

/* The status line, shared by all three JavaScript versions. Keyed on the role
   rather than a class, so it styles the element that is already announcing
   itself to a screen reader. */
[role="status"] {
  margin: 14px 0 0;
  font-size: 14px;
  text-align: center;
}

[role="status"]:empty {
  display: none;
}

[role="status"].ok {
  color: #16a34a;
}

[role="status"].bad {
  color: #dc2626;
}

What the Server Action buys you

  • The form submits before hydration and with JavaScript disabled — Next.js falls back to a native form post and runs the action on the server.
  • The endpoint never appears in the client bundle, which matters for nobody's security but does keep it out of your deployment's static output.
  • No API route to write, no route handler to keep in sync, and no third state where the request is in flight on the client.
  • The page stays a Server Component, so nothing about the form pulls React into the browser.

Always redirect, both ways

A Server Action that returns without redirecting leaves the visitor on the same page with the same filled-in form and no indication of anything happening. They will conclude it failed, and press the button again. Point both branches at pages you have actually built:

const { success } = await res.json();
redirect(success ? "/contact/thanks" : "/contact/error");
redirect() works by throwing, so never wrap it in a try/catch that swallows errors — put it after the try block or rethrow.

When to use a client component instead

Server Actions cannot show a spinner on the button by themselves, and they cannot keep the visitor on the page. If you want either, useFormStatus covers the first and a plain client-side fetch covers the second — the React contact form example is exactly that, and it posts to the same endpoint.

Static export and the Pages Router

Server Actions need a server. On output: export, or in the Pages Router, use the client-side version instead — the markup is identical and only the submit handler changes. That is also the version to use if your Next.js site is deployed as static files to a CDN.

Questions about this contact form

Server Action or API route for a Next.js contact form?
Server Action. An API route adds a hop and a file for no benefit here — the action already runs on the server and can post straight to the endpoint. Route handlers are for things you need to call from elsewhere.
Does a Server Action contact form work without JavaScript?
Yes, and that is its main advantage. Next.js renders a real form element with a POST target, so the browser submits natively before hydration or with scripts disabled entirely.
How do I show a loading state on the submit button?
Wrap the button in a small client component and read useFormStatus. It is the only part of the page that has to be a client component.
Can I use this with output: export?
No — a static export has no server to run the action. Use the client-side React version instead; the markup does not change and it posts to the same endpoint.

Related examples

Or go back to all 21 contact form examples.

Give this form an endpoint

Formpost takes the submission, emails it to you and keeps a searchable copy. Free for 250 messages a month, with unlimited forms and no card.