At a glance
- Router
- App Router
- API route
- Not needed
- Submit
- Server Action
- Where it goes
app/contact/actions.ts- Pending state
- useActionState
The integration
This is a Server Action in the App Router: no /api route, no database. It runs on the server and forwards to your endpoint; pair it with useActionState for pending state. A client-side fetch works too if your form is a client component.
// app/contact/actions.ts
'use server';
// Runs on your server, so two things differ from a browser post: the request has no Origin
// (a form with allowed domains refuses it; post from the browser instead), and every visitor
// shares your server's IP for rate limiting. For busy forms, use a client-side fetch.
export type ContactState = { ok: boolean; message: string } | null;
export async function submitContact(
_prevState: ContactState,
formData: FormData,
): Promise<ContactState> {
const response = await fetch('https://mtform.co/f/your-form-key', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(Object.fromEntries(formData)),
});
const result: { error?: { message?: string } } = await response.json();
if (!response.ok) {
return { ok: false, message: result.error?.message ?? 'Unable to send your message.' };
}
return { ok: true, message: 'Thanks. We got your message.' };
}
// app/contact/page.tsx
'use client';
import { useActionState } from 'react';
import { submitContact, type ContactState } from './actions';
export default function ContactPage() {
const [state, action, pending] = useActionState(submitContact, null as ContactState);
return (
<form action={action}>
<input name="name" placeholder="Name" required />
<input type="email" name="email" placeholder="Email" required />
<textarea name="message" placeholder="Message" required />
<input name="_mtf_honeypot" tabIndex={-1} autoComplete="off" aria-hidden="true"
style={{ position: 'absolute', left: '-9999px' }} />
<button type="submit" disabled={pending}>{pending ? 'Sending…' : 'Send'}</button>
{state && <p role={state.ok ? 'status' : 'alert'}>{state.message}</p>}
</form>
);
}Step by step
- 1
Create the endpoint
Create a MakeTheForm form to get your public endpoint URL and inbox.
- 2
Add a Server Action
In a "use server" file, write one function that fetches the endpoint with the submitted FormData as JSON and returns a serializable { ok, message }.
- 3
Wire the form and pending state
Bind the action with useActionState, disable the button while pending, and render the returned message inline.
- 4
Verify and deploy
Verify your recipient email once, deploy to Vercel or your host, and submit to confirm delivery to your inbox and email.
Common pitfalls
Assuming a Server Action needs a secret key
The form endpoint isn’t a secret. It’s a public address, and it works the same from the browser. A Server Action is a fine place to call it from, but don’t assume you now need an env-var API key. There isn’t one.
Mixing "use server" into a "use client" file
You can’t declare a Server Action inside a client component. Put the action in its own "use server" module and import it, or the build fails. Keep the form component and the action in separate files.
Troubleshooting
| Symptom | What’s happening | Fix |
|---|---|---|
| action not firing | The form uses onSubmit instead of the action prop, so the Server Action never runs. | Bind it with <form action={action}> from useActionState, or call the action from your handler. |
| pending never ends | The action throws instead of returning, so useActionState can’t settle. | Catch fetch errors and return { ok: false, message } rather than throwing inside the action. |
| 403 | Allowed-domains is on and your deployed domain isn’t listed. | Add your production and preview domains in the form’s spam settings, or leave it public. |
Frequently asked questions
Can I build a Next.js contact form without a backend?
Yes. Point the form at a hosted endpoint. Either via a client fetch or a Server Action that forwards to it. The endpoint receives, spam-filters, emails, and stores each submission, so you don’t build or host a backend of your own.
How do I use a Server Action to send a form to email?
Write a "use server" function that fetches an email-capable endpoint with the submitted FormData and returns a result. Bind it with useActionState for pending and error state. The endpoint handles the actual email delivery and spam filtering.
Do I need an /api route for a Next.js form?
No. A Server Action or a direct client-side fetch to a form endpoint both work without a Route Handler. You only need an /api route for custom server processing the endpoint doesn’t already cover.
Other platforms
Keep going
Last reviewed by the MakeTheForm team.