Next.js
Send a Next.js App Router form to MakeTheForm with a Server Action and useActionState. No API route, no email provider, no secret to manage.
Before you start
You need a form endpoint from the dashboard (see the Quickstart) and a Next.js app using the App Router. The example uses React's useActionState, so it needs Next.js 15 or later.
You do not need an API route, a database or an email provider. The endpoint URL is not a secret, so there is no key to put in an environment variable.
- 1
Add the action and the form
The example is two files: a Server Action that forwards the fields to your endpoint, and a client component that renders the form and the result. Create both under
app/contact/.Next.js// 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> ); }The action sends the fields as JSON, so the endpoint answers with JSON. It returns a plain object rather than throwing, which is what lets
useActionStatesettle and show the message.Typed for strict mode. The action exports itsContactStatetype and the page imports it, so the snippet compiles understrictas pasted. - 2
Keep the spam trap
The hidden
_mtf_honeypotinput travels with the rest of the form data, so the honeypot keeps working through the Server Action. It is positioned off-screen rather than hidden withdisplay: none, because bots skip fields they can tell are hidden. Leave it that way, and never mark itrequired. - 3
Test it
Run
next dev, open/contactand submit. The button shows Sending… while the action runs, then the success message appears under the form. The submission is at the top of Submissions in the dashboard.On an error the entered values stay in place and the message from the endpoint is shown, such as a rate limit or a paused form.
Server Action or client fetch
With a Server Action the request to MakeTheForm comes from your server, not from the visitor's browser. That is fine for most contact forms, but it changes three things:
- Rate limits count your server, not each visitor. The public limit is 5 submissions per 30 seconds per IP address and form. Every submission now arrives from your server's address, so a busy form can hit it with legitimate traffic.
- There is no browser Origin. If the form is restricted to its allowed domains, a server request carries no
Originheader and is refused withorigin_not_allowed. - Block rules see your server. Rules that block by IP address or country match your hosting provider, not the visitor.
If any of these matter, post from the browser instead. Mark the form component 'use client' and use the handler from the React guide or AJAX & fetch. For a genuine backend integration, use server-side submissions with a secret key, which are limited per key at 300 per 60 seconds instead of per IP.