Complete guide to handling form submissions in Next.js with Server Actions, API routes, and client componentsβwithout building your own backend.
MyFormConnect Team
15 min read
Next.js is one of the most popular React frameworks for building production-ready web applications. With its powerful features like Server Actions, API Routes, and the App Router, Next.js offers multiple ways to integrate form handling with MyFormConnect.
In this comprehensive guide, we'll explore different integration methods for Next.js, covering everything from simple client-side forms to advanced server-side solutions using Server Actions and API Routes.
Before we begin, make sure you have:
First, retrieve your form's submission endpoint from your MyFormConnect dashboard:
https://myformconnect.io/f/YOUR_FORM_UUIDStore this in your environment variables for security:
# .env.local
NEXT_PUBLIC_MYFORM_UUID=your-form-uuid-here
MYFORM_UUID=your-form-uuid-here # For server-side use
For client-side form handling in Next.js 13+ App Router, create a client component:
'use client';
import { useState } from 'react';
export default function ContactForm() {
const [formData, setFormData] = useState({
name: '',
email: '',
message: ''
});
const [status, setStatus] = useState('idle');
const [error, setError] = useState(null);
const FORM_UUID = process.env.NEXT_PUBLIC_MYFORM_UUID;
const API_ENDPOINT = `https://myformcapture.com/f/${FORM_UUID}`;
const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
};
const handleSubmit = async (e) => {
e.preventDefault();
setStatus('loading');
setError(null);
try {
const response = await fetch(API_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(formData),
});
if (response.ok) {
setStatus('success');
setFormData({ name: '', email: '', message: '' });
} else {
const errorData = await response.json();
throw new Error(errorData.error || 'Submission failed');
}
} catch (err) {
setStatus('error');
setError(err.message);
}
};
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="name" className="block text-sm font-medium mb-1">
Name
</label>
<input
type="text"
id="name"
name="name"
value={formData.name}
onChange={handleChange}
required
className="w-full px-3 py-2 border border-gray-300 rounded-md"
/>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium mb-1">
Email
</label>
<input
type="email"
id="email"
name="email"
value={formData.email}
onChange={handleChange}
required
className="w-full px-3 py-2 border border-gray-300 rounded-md"
/>
</div>
<div>
<label htmlFor="message" className="block text-sm font-medium mb-1">
Message
</label>
<textarea
id="message"
name="message"
value={formData.message}
onChange={handleChange}
rows="5"
required
className="w-full px-3 py-2 border border-gray-300 rounded-md"
/>
</div>
{status === 'error' && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
{error || 'Failed to submit form. Please try again.'}
</div>
)}
{status === 'success' && (
<div className="bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded">
Thank you! Your message has been sent successfully.
</div>
)}
<button
type="submit"
disabled={status === 'loading'}
className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 disabled:opacity-50"
>
{status === 'loading' ? 'Sending...' : 'Send Message'}
</button>
</form>
);
}
Server Actions are a Next.js 13+ feature that allows you to execute server-side code directly from client components. This is the recommended approach for form submissions:
// app/actions/submitForm.ts (or .js)
'use server';
export async function submitForm(formData: FormData) {
const name = formData.get('name') as string;
const email = formData.get('email') as string;
const message = formData.get('message') as string;
const FORM_UUID = process.env.MYFORM_UUID;
const API_ENDPOINT = `https://myformcapture.com/f/${FORM_UUID}`;
try {
const response = await fetch(API_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ name, email, message }),
});
if (!response.ok) {
const errorData = await response.json();
return { success: false, error: errorData.error || 'Submission failed' };
}
return { success: true };
} catch (error) {
return { success: false, error: 'Network error. Please try again.' };
}
}
'use client';
import { useState } from 'react';
import { submitForm } from '@/app/actions/submitForm';
export default function ContactForm() {
const [status, setStatus] = useState('idle');
const [error, setError] = useState(null);
async function handleSubmit(formData: FormData) {
setStatus('loading');
setError(null);
const result = await submitForm(formData);
if (result.success) {
setStatus('success');
// Reset form
const form = document.getElementById('contact-form') as HTMLFormElement;
form?.reset();
} else {
setStatus('error');
setError(result.error);
}
}
return (
<form id="contact-form" action={handleSubmit} className="space-y-4">
<div>
<label htmlFor="name" className="block text-sm font-medium mb-1">
Name
</label>
<input
type="text"
id="name"
name="name"
required
className="w-full px-3 py-2 border border-gray-300 rounded-md"
/>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium mb-1">
Email
</label>
<input
type="email"
id="email"
name="email"
required
className="w-full px-3 py-2 border border-gray-300 rounded-md"
/>
</div>
<div>
<label htmlFor="message" className="block text-sm font-medium mb-1">
Message
</label>
<textarea
id="message"
name="message"
rows="5"
required
className="w-full px-3 py-2 border border-gray-300 rounded-md"
/>
</div>
{status === 'error' && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
{error || 'Failed to submit form. Please try again.'}
</div>
)}
{status === 'success' && (
<div className="bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded">
Thank you! Your message has been sent successfully.
</div>
)}
<button
type="submit"
disabled={status === 'loading'}
className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 disabled:opacity-50"
>
{status === 'loading' ? 'Sending...' : 'Send Message'}
</button>
</form>
);
}
API Routes provide another way to handle form submissions. Create an API route in the App Router:
// app/api/submit-form/route.ts (or .js)
import { NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const FORM_UUID = process.env.MYFORM_UUID;
const API_ENDPOINT = `https://myformcapture.com/f/${FORM_UUID}`;
const response = await fetch(API_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
if (!response.ok) {
const errorData = await response.json();
return NextResponse.json(
{ error: errorData.error || 'Submission failed' },
{ status: response.status }
);
}
const data = await response.json();
return NextResponse.json(data, { status: 200 });
} catch (error) {
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}
'use client';
import { useState } from 'react';
export default function ContactForm() {
const [formData, setFormData] = useState({
name: '',
email: '',
message: ''
});
const [status, setStatus] = useState('idle');
const [error, setError] = useState(null);
const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
};
const handleSubmit = async (e) => {
e.preventDefault();
setStatus('loading');
setError(null);
try {
const response = await fetch('/api/submit-form', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(formData),
});
if (response.ok) {
setStatus('success');
setFormData({ name: '', email: '', message: '' });
} else {
const errorData = await response.json();
throw new Error(errorData.error || 'Submission failed');
}
} catch (err) {
setStatus('error');
setError(err.message);
}
};
// ... rest of form JSX (same as previous examples)
}
If you're using the Pages Router (Next.js 12 or earlier), use API routes:
// pages/api/submit-form.js
export default async function handler(req, res) {
if (req.method !== 'POST') {
return res.status(405).json({ error: 'Method not allowed' });
}
const FORM_UUID = process.env.MYFORM_UUID;
const API_ENDPOINT = `https://myformcapture.com/f/${FORM_UUID}`;
try {
const response = await fetch(API_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(req.body),
});
if (!response.ok) {
const errorData = await response.json();
return res.status(response.status).json(errorData);
}
const data = await response.json();
return res.status(200).json(data);
} catch (error) {
return res.status(500).json({ error: 'Internal server error' });
}
}
For robust validation, use Zod with Server Actions:
// First, install Zod: npm install zod
// app/actions/submitForm.ts
'use server';
import { z } from 'zod';
const formSchema = z.object({
name: z.string().min(2, 'Name must be at least 2 characters'),
email: z.string().email('Invalid email address'),
message: z.string().min(10, 'Message must be at least 10 characters'),
});
export async function submitForm(formData: FormData) {
const rawData = {
name: formData.get('name'),
email: formData.get('email'),
message: formData.get('message'),
};
// Validate with Zod
const validationResult = formSchema.safeParse(rawData);
if (!validationResult.success) {
return {
success: false,
error: 'Validation failed',
errors: validationResult.error.flatten().fieldErrors,
};
}
const FORM_UUID = process.env.MYFORM_UUID;
const API_ENDPOINT = `https://myformcapture.com/f/${FORM_UUID}`;
try {
const response = await fetch(API_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(validationResult.data),
});
if (!response.ok) {
const errorData = await response.json();
return { success: false, error: errorData.error || 'Submission failed' };
}
return { success: true };
} catch (error) {
return { success: false, error: 'Network error. Please try again.' };
}
}
Handle file uploads with Server Actions:
'use server';
export async function submitFormWithFile(formData: FormData) {
const name = formData.get('name') as string;
const email = formData.get('email') as string;
const message = formData.get('message') as string;
const file = formData.get('file') as File | null;
const FORM_UUID = process.env.MYFORM_UUID;
const API_ENDPOINT = `https://myformcapture.com/f/${FORM_UUID}`;
const body = new FormData();
body.append('name', name);
body.append('email', email);
body.append('message', message);
if (file) {
body.append('file', file);
}
try {
const response = await fetch(API_ENDPOINT, {
method: 'POST',
body: body, // Don't set Content-Type - browser will set it with boundary
});
if (!response.ok) {
const errorData = await response.json();
return { success: false, error: errorData.error || 'Submission failed' };
}
return { success: true };
} catch (error) {
return { success: false, error: 'Network error. Please try again.' };
}
}
If you're displaying form submissions on your site, you can revalidate the cache after submission:
'use server';
import { revalidatePath } from 'next/cache';
export async function submitForm(formData: FormData) {
// ... form submission logic ...
// Revalidate the submissions page after successful submission
if (response.ok) {
revalidatePath('/submissions');
return { success: true };
}
}
Use the useFormStatus hook for better loading states:
'use client';
import { useFormStatus } from 'react-dom';
import { submitForm } from '@/app/actions/submitForm';
function SubmitButton() {
const { pending } = useFormStatus();
return (
<button
type="submit"
disabled={pending}
className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 disabled:opacity-50"
>
{pending ? 'Sending...' : 'Send Message'}
</button>
);
}
export default function ContactForm() {
return (
<form action={submitForm} className="space-y-4">
{/* Form fields */}
<SubmitButton />
</form>
);
}
MYFORM_UUID (without NEXT_PUBLIC_) for server-side code.| Feature | Server Actions | API Routes |
|---|---|---|
| Next.js Version | 13+ (App Router) | All versions |
| Type Safety | β Better with TypeScript | β οΈ Manual typing |
| Performance | β Faster (no API overhead) | β οΈ Additional HTTP request |
| Complexity | β Simpler | β οΈ More boilerplate |
| Use Case | Form submissions, mutations | REST APIs, external integrations |
If Server Actions aren't working:
'use server' directive at the topIf API routes aren't responding:
app/api/ or pages/api/)If environment variables aren't working:
.env.localNEXT_PUBLIC_ prefix only for client-side variables.env.local is in your .gitignoreHere's a complete, production-ready example using Server Actions with TypeScript:
// app/actions/submitForm.ts
'use server';
import { z } from 'zod';
import { revalidatePath } from 'next/cache';
const formSchema = z.object({
name: z.string().min(2, 'Name must be at least 2 characters'),
email: z.string().email('Invalid email address'),
message: z.string().min(10, 'Message must be at least 10 characters'),
});
export type FormState = {
success?: boolean;
error?: string;
errors?: Record<string, string[]>;
};
export async function submitForm(
prevState: FormState | null,
formData: FormData
): Promise<FormState> {
const rawData = {
name: formData.get('name'),
email: formData.get('email'),
message: formData.get('message'),
};
const validationResult = formSchema.safeParse(rawData);
if (!validationResult.success) {
return {
success: false,
errors: validationResult.error.flatten().fieldErrors,
};
}
const FORM_UUID = process.env.MYFORM_UUID;
if (!FORM_UUID) {
return { success: false, error: 'Form UUID not configured' };
}
const API_ENDPOINT = `https://myformcapture.com/f/${FORM_UUID}`;
try {
const response = await fetch(API_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(validationResult.data),
});
if (!response.ok) {
const errorData = await response.json();
return { success: false, error: errorData.error || 'Submission failed' };
}
revalidatePath('/contact');
return { success: true };
} catch (error) {
return { success: false, error: 'Network error. Please try again.' };
}
}
// app/components/ContactForm.tsx
'use client';
import { useActionState } from 'react';
import { submitForm, FormState } from '@/app/actions/submitForm';
export default function ContactForm() {
const [state, formAction, isPending] = useActionState(submitForm, null);
return (
<form action={formAction} className="space-y-4">
<div>
<label htmlFor="name" className="block text-sm font-medium mb-1">
Name *
</label>
<input
type="text"
id="name"
name="name"
required
className="w-full px-3 py-2 border border-gray-300 rounded-md"
aria-invalid={state?.errors?.name ? 'true' : 'false'}
/>
{state?.errors?.name && (
<p className="text-red-600 text-sm mt-1">{state.errors.name[0]}</p>
)}
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium mb-1">
Email *
</label>
<input
type="email"
id="email"
name="email"
required
className="w-full px-3 py-2 border border-gray-300 rounded-md"
aria-invalid={state?.errors?.email ? 'true' : 'false'}
/>
{state?.errors?.email && (
<p className="text-red-600 text-sm mt-1">{state.errors.email[0]}</p>
)}
</div>
<div>
<label htmlFor="message" className="block text-sm font-medium mb-1">
Message *
</label>
<textarea
id="message"
name="message"
rows="5"
required
className="w-full px-3 py-2 border border-gray-300 rounded-md"
aria-invalid={state?.errors?.message ? 'true' : 'false'}
/>
{state?.errors?.message && (
<p className="text-red-600 text-sm mt-1">{state.errors.message[0]}</p>
)}
</div>
{state?.error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
{state.error}
</div>
)}
{state?.success && (
<div className="bg-green-50 border border-green-200 text-green-700 px-4 py-3 rounded">
Thank you! Your message has been sent successfully. We'll get back to you soon.
</div>
)}
<button
type="submit"
disabled={isPending}
className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 disabled:opacity-50"
>
{isPending ? 'Sending...' : 'Send Message'}
</button>
</form>
);
}
Next.js offers powerful and flexible ways to integrate MyFormConnect, from simple client-side forms to advanced server-side solutions. Whether you choose Server Actions, API Routes, or client components, MyFormConnect provides a reliable backend for handling form submissions.
Key takeaways:
Ready to get started? Sign up for MyFormConnect and start collecting form submissions in your Next.js app today!
Create your free MyFormConnect account and start collecting form submissions in your Next.js app in minutes.
Start Free TrialNo credit card required β’ 5-minute setup β’ Next.js-ready API
Get form and lead-capture tips in your inbox.
MyFormConnect Team
Our team of experts helps businesses improve their lead capture and conversion rates through strategic form design and implementation.