Free plan250 submissions a month, spam filtering included, no card.See pricing →
Framework · Next.js

Next.js contact form, no API route

Next.js is a full-stack framework, so you could build a Route Handler and wire up an email SDK. For a contact or lead form that’s more surface area than the job needs. The App Router gives you a cleaner option, a Server Action that forwards to an endpoint, or a plain client-side fetch. Both skip the /api route entirely.

3 min readApp RouterLast reviewed

On this page

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.

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>
  );
}

Step by step

  1. 1

    Create the endpoint

    Create a MakeTheForm form to get your public endpoint URL and inbox.

  2. 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. 3

    Wire the form and pending state

    Bind the action with useActionState, disable the button while pending, and render the returned message inline.

  4. 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

SymptomWhat’s happeningFix
action not firingThe 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 endsThe action throws instead of returning, so useActionState can’t settle.Catch fetch errors and return { ok: false, message } rather than throwing inside the action.
403Allowed-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

Ship this form for real.

Create an endpoint, paste it in and watch the first submission land in your inbox.

Create free endpoint