Next.js
Published: 27-Jul-2026 Updated: 12-Aug-2026

How to Submit a Form in Next.js Without Building a Backend

Copy-paste Next.js App Router tutorial: native form action or fetch with FormData to a MyFormConnect endpoint. No API routes or mail code required.

MFC

MyFormConnect Team

This tutorial wires a Next.js App Router contact form to MyFormConnect so submissions land in your dashboard, email, and lead list — without an API route, Server Action, or SMTP code.

You will end with a copy-paste Client Component. Use a native action if you want a full-page redirect. Use fetch if you want an inline success state on the same page.

What you need

  1. A Next.js app on the App Router (app/). Next.js 14 or 15 is fine.
  2. A MyFormConnect form. Copy the Form Action URL from the form page — it looks like https://myformconnect.io/f/YOUR_FORM_UUID.
  3. Replace YOUR_FORM_UUID with the UUID from your dashboard.

Set Redirect URL in form settings for Option 1 (native POST). For Option 2 (fetch), handle success in React instead. If Domain Restriction is on, add http://localhost:3000 for local next dev.

Step 1: Environment variable

Put the public Form Action URL in .env.local at the project root, then restart the dev server.

# .env.local
NEXT_PUBLIC_FORM_ENDPOINT=https://myformconnect.io/f/YOUR_FORM_UUID

NEXT_PUBLIC_ is required so the browser can read it. The Form Action URL is not a secret. Never put API keys or CAPTCHA secret keys in NEXT_PUBLIC_ variables.

Step 2a: Native form action (redirect after submit)

A string action is a normal HTML POST. Next.js does not intercept it. No 'use client', no fetch. After submit, MyFormConnect redirects to the Redirect URL from form settings.

// app/contact/page.tsx

const endpoint = process.env.NEXT_PUBLIC_FORM_ENDPOINT;

export default function ContactPage() {
  if (!endpoint) {
    throw new Error('Set NEXT_PUBLIC_FORM_ENDPOINT in .env.local');
  }

  return (
    <main>
      <h1>Contact us</h1>
      <form action={endpoint} method="POST">
        <label htmlFor="name">Name</label>
        <input id="name" name="name" type="text" required autoComplete="name" />

        <label htmlFor="email">Email</label>
        <input id="email" name="email" type="email" required autoComplete="email" />

        <label htmlFor="message">Message</label>
        <textarea id="message" name="message" rows={5} required />

        <button type="submit">Send message</button>
      </form>
    </main>
  );
}

Field name attributes become columns in Responses. Keep them stable.

Step 2b: Client fetch (stay on the page)

Use a Client Component when you want a loading state and an inline thank-you message.

Post FormData — do not JSON.stringify the fields, and do not set Content-Type (the browser must set the multipart boundary). Send X-Requested-With: XMLHttpRequest so the endpoint returns JSON instead of a redirect. A 201 Created response is success (response.ok is true).

Copy-paste: ContactForm + page

Save as app/components/ContactForm.tsx (or components/ContactForm.tsx if that is your alias). Then import it from the contact page.

'use client';

import { useState, type FormEvent } from 'react';

const ENDPOINT = process.env.NEXT_PUBLIC_FORM_ENDPOINT;

export function ContactForm() {
  const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
  const [errorMessage, setErrorMessage] = useState('');

  async function handleSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();

    if (!ENDPOINT) {
      setStatus('error');
      setErrorMessage('Missing NEXT_PUBLIC_FORM_ENDPOINT in .env.local');
      return;
    }

    const form = event.currentTarget;
    setStatus('loading');
    setErrorMessage('');

    try {
      const response = await fetch(ENDPOINT, {
        method: 'POST',
        headers: {
          Accept: 'application/json',
          'X-Requested-With': 'XMLHttpRequest',
        },
        body: new FormData(form),
      });

      if (!response.ok) {
        const payload = (await response.json().catch(() => null)) as
          | { error?: string; message?: string }
          | null;
        throw new Error(payload?.error || payload?.message || 'Submission failed');
      }

      setStatus('success');
      form.reset();
    } catch {
      setStatus('error');
      setErrorMessage('Something went wrong. Please try again.');
    }
  }

  if (status === 'success') {
    return <p>Thanks. We will be in touch soon.</p>;
  }

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label htmlFor="name">Name</label>
        <input
          id="name"
          name="name"
          type="text"
          required
          autoComplete="name"
          disabled={status === 'loading'}
        />
      </div>

      <div>
        <label htmlFor="email">Email</label>
        <input
          id="email"
          name="email"
          type="email"
          required
          autoComplete="email"
          disabled={status === 'loading'}
        />
      </div>

      <div>
        <label htmlFor="message">Message</label>
        <textarea
          id="message"
          name="message"
          rows={5}
          required
          disabled={status === 'loading'}
        />
      </div>

      {status === 'error' && <p role="alert">{errorMessage}</p>}

      <button type="submit" disabled={status === 'loading'}>
        {status === 'loading' ? 'Sending…' : 'Send message'}
      </button>
    </form>
  );
}
// app/contact/page.tsx

import { ContactForm } from '../components/ContactForm';

export default function ContactPage() {
  return (
    <main>
      <h1>Contact us</h1>
      <ContactForm />
    </main>
  );
}

Adjust the import path if your ContactForm.tsx lives under @/components (import { ContactForm } from '@/components/ContactForm'). Open /contact, submit a real email address, then check Responses in the MyFormConnect dashboard.

Rules that keep this working

  • Use FormDataJSON.stringify(Object.fromEntries(formData)) is not the supported submit format.
  • Do not set Content-Type on fetch when the body is FormData.
  • Send X-Requested-With: XMLHttpRequest so you get JSON instead of a redirect (redirects break cross-origin fetch).
  • Treat response.ok as success — a normal create is HTTP 201. The JSON body uses message, not success: true.
  • Import FormEvent from react — App Router Client Components do not put React in scope unless you import it.

Pages Router

Drop the 'use client' line. Keep the same fetch + FormData component and import it from pages/contact.tsx. A native <form action={endpoint} method="POST"> also works in pages/.

When you still want a Route Handler

Post from the browser unless you need to hide the form UUID. Then proxy from app/api/submit/route.ts with a server-only env var (FORM_ENDPOINT, no NEXT_PUBLIC_) and forward FormData. Do not invent a Server Action just to collect a public lead — it only adds a hop.

Spam protection

Use HTML5 required and type="email" in the browser. Enable honeypot, rate limiting, and content scoring in MyFormConnect form settings. For high-spam or high-value forms, embed with Loader.js and CAPTCHA. Do not put CAPTCHA secret keys in Next.js client code.

File uploads

Native POST: add encType="multipart/form-data" on the <form>. Client fetch: new FormData(form) already includes files — still do not set Content-Type. Limits: Forms with File Upload.

Which path to use

Start with the native action if a thank-you page is enough. Use the copy-paste ContactForm when the UI must stay on the same route. Change notifications, Redirect URL, and spam settings in the dashboard — you do not need to redeploy Next.js for those.

Ready to collect form submissions?

Create a free MyFormConnect form endpoint and skip building your own backend.

Start Free Trial

No credit card required · 5-minute setup