How to Submit a Form in Next.js Without Building a Backend
Submit Next.js forms without API routes, Server Actions databases, or custom email code. Point fetch() or a native form action at a managed form endpoint.
MyFormConnect Team
Next.js has several ways to handle form submissions — Route Handlers, Server Actions, and client-side fetch. For a simple contact or lead form, you probably don't need any of them. This guide shows how to wire a Next.js form to an external endpoint so you collect leads without writing any server-side code of your own.
Route Handlers and Server Actions make sense when you need to process form data alongside your application — saving to your own database, authenticating users, or calling internal services. For a public contact form or newsletter sign-up that just needs to land somewhere and send you an email, they're unnecessary complexity. You can post directly to an external endpoint instead.
A plain HTML action attribute works in Next.js. The browser handles the submission natively — no JavaScript required.
// app/contact/page.tsx (or .jsx)
export default function ContactPage() {
return (
<main>
<h1>Contact us</h1>
<form action="https://myformconnect.io/f/YOUR_FORM_ID" method="POST">
<label htmlFor="name">Name</label>
<input id="name" name="name" type="text" required />
<label htmlFor="email">Email address</label>
<input id="email" name="email" type="email" required />
<label htmlFor="message">Message</label>
<textarea id="message" name="message" rows={5} required />
<input type="hidden" name="_redirect" value="https://yoursite.com/thank-you" />
<button type="submit">Send message</button>
</form>
</main>
);
}This works in both the App Router and the Pages Router. No 'use client' directive, no useState, no event handlers needed.
If you want to show a success or error message on the same page — without a full redirect — use a Client Component and fetch.
'use client';
import { useState } from 'react';
export function ContactForm() {
const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
setStatus('loading');
const form = e.currentTarget;
const data = Object.fromEntries(new FormData(form));
try {
const res = await fetch('https://myformconnect.io/f/YOUR_FORM_ID', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify(data),
});
if (res.ok) {
setStatus('success');
form.reset();
} else {
setStatus('error');
}
} catch {
setStatus('error');
}
}
if (status === 'success') {
return <p>Thank you — we'll get back to you shortly.</p>;
}
return (
<form onSubmit={handleSubmit}>
<label htmlFor="name">Name</label>
<input id="name" name="name" type="text" required />
<label htmlFor="email">Email address</label>
<input id="email" name="email" type="email" required />
<label htmlFor="message">Message</label>
<textarea id="message" name="message" rows={5} required />
{status === 'error' && (
<p style={{ color: 'red' }}>Something went wrong. Please try again.</p>
)}
<button type="submit" disabled={status === 'loading'}>
{status === 'loading' ? 'Sending…' : 'Send message'}
</button>
</form>
);
}Import and render this component from any Server Component page:
// app/contact/page.tsx
import { ContactForm } from '@/components/ContactForm';
export default function ContactPage() {
return (
<main>
<h1>Contact us</h1>
<ContactForm />
</main>
);
}Server Actions are a good fit when the form interacts with your own database, requires authentication, or processes sensitive data that shouldn't be in client bundles. For a public contact form that just needs to store a message and send a notification email, they add unnecessary complexity — you'd essentially be proxying a request that could go directly to the endpoint.
A reasonable rule: if the form submission triggers actions inside your application, use a Server Action. If it's simply collecting external leads, post directly to the endpoint.
If you want to avoid hard-coding the endpoint URL and be able to switch it per environment:
# .env.local
NEXT_PUBLIC_FORM_ENDPOINT=https://myformconnect.io/f/YOUR_FORM_ID// In your component
const endpoint = process.env.NEXT_PUBLIC_FORM_ENDPOINT;The NEXT_PUBLIC_ prefix makes the variable available in the browser bundle. This is fine for a public form endpoint URL — it's not a secret. Never put API keys or private tokens in NEXT_PUBLIC_ variables.
Use HTML5 attributes (required, type="email", minLength) for client-side validation. For server-side spam protection, enable honeypot and scoring in your form settings. This keeps bots out without adding any validation code to your Next.js application.
If you're adding a contact or lead form to a Next.js project, start with the native form action (Option 1). It works without JavaScript, requires no client component, and handles every browser correctly. If your design requires an inline success state, move to the fetch pattern (Option 2). Only reach for Server Actions when the form needs to interact with your own application — authenticated flows, database writes, or internal APIs.
The form endpoint configuration (spam protection, notifications, redirect) is separate from your Next.js code, so you can adjust it any time without a redeployment.
Create a free MyFormCapture form endpoint and skip building your own backend.
Start Free TrialNo credit card required · 5-minute setup