Build a React Contact Form with Email Notifications in 5 Minutes
A complete tutorial for creating a contact form in React with client-side validation, loading states, and automatic email notifications — no backend code required.
What We Are Building
In this tutorial, we are going to build a fully functional contact form in React that:
- Collects name, email, and message from the user
- Validates all fields before submission
- Shows a loading spinner while submitting
- Displays a success message after submission
- Sends an email notification to you automatically
- Includes spam protection
We are going to use FormCatch as our form backend, which means we do not need to write any server-side code. The entire solution runs in the browser and takes about 5 minutes to set up.
Prerequisites
- Basic knowledge of React (hooks, state, events)
- A React project (Create React App, Next.js, Vite, or any setup)
- A free FormCatch account (sign up here)
Step 1: Set Up FormCatch
First, create a free account at formcatch.vercel.app. Once logged in, click “Create Endpoint” and name it something like “Contact Form.” You will get a unique endpoint URL:
https://formcatch.vercel.app/s/abc123def456Copy this URL. You will use it in your React component to POST form data to.
Step 2: Create the Contact Form Component
Create a new file called ContactForm.tsx (or .jsx if you are not using TypeScript) in your components directory. Here is the complete component:
'use client';
import { useState, FormEvent } from 'react';
// Replace with your actual FormCatch endpoint URL
const FORMCATCH_URL = 'https://formcatch.vercel.app/s/your-form-id';
interface FormData {
name: string;
email: string;
message: string;
}
interface FormErrors {
name?: string;
email?: string;
message?: string;
}
export default function ContactForm() {
const [formData, setFormData] = useState<FormData>({
name: '',
email: '',
message: '',
});
const [errors, setErrors] = useState<FormErrors>({});
const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
const validate = (): boolean => {
const newErrors: FormErrors = {};
if (!formData.name.trim()) {
newErrors.name = 'Name is required';
}
if (!formData.email.trim()) {
newErrors.email = 'Email is required';
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
newErrors.email = 'Please enter a valid email';
}
if (!formData.message.trim()) {
newErrors.message = 'Message is required';
} else if (formData.message.trim().length < 10) {
newErrors.message = 'Message must be at least 10 characters';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
if (!validate()) return;
setStatus('loading');
try {
const body = new FormData();
body.append('name', formData.name);
body.append('email', formData.email);
body.append('message', formData.message);
const res = await fetch(FORMCATCH_URL, {
method: 'POST',
body,
});
if (res.ok) {
setStatus('success');
setFormData({ name: '', email: '', message: '' });
} else {
setStatus('error');
}
} catch {
setStatus('error');
}
};
if (status === 'success') {
return (
<div className="text-center py-12">
<div className="text-4xl mb-4">✓</div>
<h3 className="text-xl font-semibold mb-2">Message Sent!</h3>
<p className="text-gray-500 mb-4">
Thank you for reaching out. We will get back to you soon.
</p>
<button
onClick={() => setStatus('idle')}
className="text-blue-500 hover:text-blue-400"
>
Send another message
</button>
</div>
);
}
return (
<form onSubmit={handleSubmit} className="space-y-6 max-w-lg mx-auto">
{/* Honeypot field for spam protection */}
<input
type="text"
name="_gotcha"
style={{ display: 'none' }}
tabIndex={-1}
autoComplete="off"
/>
<div>
<label htmlFor="name" className="block text-sm font-medium mb-1">
Name
</label>
<input
id="name"
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full px-4 py-2.5 rounded-lg border border-gray-700
bg-gray-900 text-white focus:ring-2 focus:ring-blue-500
focus:border-transparent"
placeholder="Your name"
/>
{errors.name && (
<p className="text-red-400 text-sm mt-1">{errors.name}</p>
)}
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium mb-1">
Email
</label>
<input
id="email"
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
className="w-full px-4 py-2.5 rounded-lg border border-gray-700
bg-gray-900 text-white focus:ring-2 focus:ring-blue-500
focus:border-transparent"
placeholder="you@example.com"
/>
{errors.email && (
<p className="text-red-400 text-sm mt-1">{errors.email}</p>
)}
</div>
<div>
<label htmlFor="message" className="block text-sm font-medium mb-1">
Message
</label>
<textarea
id="message"
rows={5}
value={formData.message}
onChange={(e) => setFormData({ ...formData, message: e.target.value })}
className="w-full px-4 py-2.5 rounded-lg border border-gray-700
bg-gray-900 text-white focus:ring-2 focus:ring-blue-500
focus:border-transparent resize-y"
placeholder="How can we help?"
/>
{errors.message && (
<p className="text-red-400 text-sm mt-1">{errors.message}</p>
)}
</div>
{status === 'error' && (
<p className="text-red-400 text-sm">
Something went wrong. Please try again.
</p>
)}
<button
type="submit"
disabled={status === 'loading'}
className="w-full bg-blue-500 hover:bg-blue-600 text-white py-3
rounded-lg font-medium transition-colors disabled:opacity-50
disabled:cursor-not-allowed"
>
{status === 'loading' ? 'Sending...' : 'Send Message'}
</button>
</form>
);
}Let us break down what this component does:
State Management
We use three pieces of state: formData holds the current values of each field, errors holds any validation errors, and status tracks the submission lifecycle (idle, loading, success, or error).
Validation
The validate function checks that all fields are filled in, that the email matches a basic email regex, and that the message is at least 10 characters long. If any field fails validation, we display an error message below it. Validation runs before every submission attempt.
Form Submission
When the form passes validation, we create a FormData object and POST it to our FormCatch endpoint using fetch. FormCatch handles the rest: storing the submission, sending an email notification to you, and returning a success response.
Spam Protection
The hidden _gotcha field is a honeypot. It is invisible to real users but gets filled in by bots. FormCatch automatically discards submissions where this field has a value.
Step 3: Use the Component
Import and render the ContactForm component anywhere in your app:
import ContactForm from '@/components/ContactForm';
export default function ContactPage() {
return (
<div className="max-w-2xl mx-auto py-16 px-4">
<h1 className="text-3xl font-bold text-center mb-8">Contact Us</h1>
<p className="text-gray-500 text-center mb-12">
Have a question? Send us a message and we will get back to you.
</p>
<ContactForm />
</div>
);
}That is all the code you need. No backend server, no API routes, no email service configuration. FormCatch handles everything.
Step 4: Customize Email Notifications
By default, FormCatch sends email notifications to the address you signed up with. You can customize this in your FormCatch dashboard:
- Add multiple notification email addresses
- Set a custom notification email subject line
- Toggle email notifications on or off per endpoint
Log into your FormCatch dashboard, click on your endpoint, and configure the notification settings.
Adding TypeScript Support
The component above is already written in TypeScript. If you are using plain JavaScript, simply remove the type annotations (FormData, FormErrors, FormEvent) and rename the file to .jsx. The logic is identical.
Using with Next.js App Router
If you are using Next.js with the App Router, make sure to add 'use client' at the top of the component file (already included in our example). This is required because the component uses React hooks (useState), which only work in client components.
// This is a Server Component (default in App Router)
// It can import a Client Component
import ContactForm from '@/components/ContactForm';
export const metadata = {
title: 'Contact Us',
description: 'Get in touch with us',
};
export default function ContactPage() {
return (
<main className="max-w-2xl mx-auto py-16 px-4">
<h1 className="text-3xl font-bold text-center mb-8">Contact Us</h1>
<ContactForm />
</main>
);
}Advanced: JSON Submission
If you prefer to send JSON instead of FormData, you can modify the fetch call:
const res = await fetch(FORMCATCH_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: formData.name,
email: formData.email,
message: formData.message,
}),
});FormCatch accepts both multipart/form-data, application/x-www-form-urlencoded, and application/json content types. Use whichever is most convenient for your project.
Summary
In this tutorial, we built a complete React contact form with:
- Client-side validation with error messages
- Loading state during submission
- Success and error feedback
- Honeypot spam protection
- Automatic email notifications via FormCatch
- Full TypeScript support
- Compatibility with Next.js App Router
The entire solution requires zero backend code. FormCatch handles submission storage, email delivery, and spam filtering. You can view all submissions in your dashboard and export them to CSV anytime.
Frequently Asked Questions
How do I send form data from React to an email?
You cannot send emails directly from React because it runs in the browser. Instead, use a form backend service like FormCatch. Submit your form data to a FormCatch endpoint using fetch, and FormCatch will send an email notification to your configured address automatically.
Do I need a backend server for a React contact form?
No. You can use a form backend API service like FormCatch instead of building your own server. Just POST your form data to a FormCatch endpoint and it handles storage, email notifications, and spam protection for you.
How do I add form validation in React?
You can use React state to track form values and validate them before submission. Check for required fields, valid email format, and minimum message length. Display error messages conditionally based on validation state. For complex forms, consider libraries like react-hook-form or Formik.
Can I use FormCatch with Next.js?
Yes. FormCatch works with any React framework including Next.js, Gatsby, Remix, and Create React App. Since FormCatch provides a standard HTTP endpoint, you can submit forms using fetch from any JavaScript environment.
Build your React contact form in 5 minutes
Free plan includes 50 submissions per month. No credit card required.
Start Free — No Credit Card Required