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.
Replace YOUR_FORM_ID with a form of your own and this sends to your inbox.
// 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>
);
}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");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
React contact form
One component, four states, no form library and no field-by-field state.
JavaScript contact form
Vanilla fetch with a real status message and proper error handling.
Contact form without a backend
For static sites: no server, no database, no serverless function.
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.