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 MyFormCapture endpoint. No API routes or mail code required.
MyFormConnect Team
This tutorial wires a Next.js App Router contact form to MyFormCapture 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.
app/). Next.js 14 or 15 is fine.https://myformcapture.com/f/YOUR_FORM_UUID.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.
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://myformcapture.com/f/YOUR_FORM_UUIDNEXT_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.
A string action is a normal HTML POST. Next.js does not intercept it. No 'use client', no fetch. After submit, MyFormCapture 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.
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).
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 MyFormCapture dashboard.
FormData — JSON.stringify(Object.fromEntries(formData)) is not the supported submit format.Content-Type on fetch when the body is FormData.X-Requested-With: XMLHttpRequest so you get JSON instead of a redirect (redirects break cross-origin fetch).response.ok as success — a normal create is HTTP 201. The JSON body uses message, not success: true.FormEvent from react — App Router Client Components do not put React in scope unless you import it.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/.
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.
Use HTML5 required and type="email" in the browser. Enable honeypot, rate limiting, and content scoring in MyFormCapture 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.
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.
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.
Create a free MyFormCapture form endpoint and skip building your own backend.
Start Free TrialNo credit card required · 5-minute setup