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.

10 minWorks on every planUpdated Sep 22, 2026

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. 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 useActionState settle and show the message.

    Typed for strict mode. The action exports its ContactState type and the page imports it, so the snippet compiles under strict as pasted.
  2. 2

    Keep the spam trap

    The hidden _mtf_honeypot input 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 with display: none, because bots skip fields they can tell are hidden. Leave it that way, and never mark it required.

  3. 3

    Test it

    Run next dev, open /contact and 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 Origin header and is refused with origin_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.

Never put a secret key in client code. The public endpoint URL is safe in the browser. A secret key is not: keep it in a server environment variable and use it only from a Server Action or Route Handler.

Next steps