Email Integration in React: Building Contact Forms with EmailJS and Server Actions
Build production-ready contact forms in React and Next.js using EmailJS, Server Actions, form validation, spam protection, and accessible error handling.
Email Integration in React: Building Contact Forms with EmailJS and Server Actions
Every portfolio and business site needs a contact form that actually works. But shipping a reliable form involves more than wiring up an email API — you need validation, spam protection, accessible error states, and a smooth user experience. This guide covers two proven approaches: EmailJS for client-side simplicity and Next.js Server Actions for full server-side control.
Approach 1: EmailJS (Client-Side)
EmailJS sends emails directly from the browser without a backend server — perfect for static sites, portfolios, and landing pages.
Setup
npm install @emailjs/browser
Configure your email service at emailjs.com:
- Connect your email provider (Gmail, Outlook, etc.)
- Create an email template with variables like
{{from_name}},{{message}} - Get your Service ID, Template ID, and Public Key
Contact Form Component
'use client';
import { useState, useRef } from 'react';
import emailjs from '@emailjs/browser';
interface FormState {
status: 'idle' | 'sending' | 'success' | 'error';
message: string;
}
export function ContactForm() {
const formRef = useRef<HTMLFormElement>(null);
const [formState, setFormState] = useState<FormState>({
status: 'idle',
message: '',
});
const [errors, setErrors] = useState<Record<string, string>>({});
const validate = (): boolean => {
const form = formRef.current;
if (!form) return false;
const newErrors: Record<string, string> = {};
const name = (form.elements.namedItem('from_name') as HTMLInputElement).value;
const email = (form.elements.namedItem('from_email') as HTMLInputElement).value;
const message = (form.elements.namedItem('message') as HTMLTextAreaElement).value;
if (!name.trim()) newErrors.from_name = 'Name is required';
if (!email.trim()) {
newErrors.from_email = 'Email is required';
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
newErrors.from_email = 'Please enter a valid email';
}
if (!message.trim()) newErrors.message = 'Message is required';
if (message.trim().length > 5000) newErrors.message = 'Message is too long (max 5000 characters)';
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!validate() || !formRef.current) return;
setFormState({ status: 'sending', message: '' });
try {
await emailjs.sendForm(
process.env.NEXT_PUBLIC_EMAILJS_SERVICE_ID!,
process.env.NEXT_PUBLIC_EMAILJS_TEMPLATE_ID!,
formRef.current,
process.env.NEXT_PUBLIC_EMAILJS_PUBLIC_KEY!
);
setFormState({
status: 'success',
message: 'Message sent successfully! I\'ll get back to you soon.',
});
formRef.current.reset();
} catch {
setFormState({
status: 'error',
message: 'Failed to send message. Please try again or email me directly.',
});
}
};
return (
<form
ref={formRef}
onSubmit={handleSubmit}
noValidate
aria-label="Contact form"
className="space-y-4 max-w-lg"
>
{/* Honeypot field for spam protection */}
<input
type="text"
name="honeypot"
className="hidden"
tabIndex={-1}
autoComplete="off"
/>
<FormField
label="Name"
name="from_name"
type="text"
error={errors.from_name}
required
/>
<FormField
label="Email"
name="from_email"
type="email"
error={errors.from_email}
required
/>
<div>
<label htmlFor="message" className="block text-sm font-medium text-gray-300 mb-1">
Message <span aria-hidden="true" className="text-red-400">*</span>
</label>
<textarea
id="message"
name="message"
rows={5}
required
aria-required="true"
aria-invalid={!!errors.message}
aria-describedby={errors.message ? 'message-error' : undefined}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-3
text-white placeholder:text-gray-500 focus:border-cyan-500
focus:ring-1 focus:ring-cyan-500 transition"
placeholder="Tell me about your project..."
/>
{errors.message && (
<p id="message-error" role="alert" className="text-sm text-red-400 mt-1">
{errors.message}
</p>
)}
</div>
<button
type="submit"
disabled={formState.status === 'sending'}
className="w-full py-3 bg-cyan-600 text-white rounded-lg font-medium
hover:bg-cyan-700 disabled:opacity-50 disabled:cursor-not-allowed
focus-visible:ring-2 focus-visible:ring-cyan-500 focus-visible:ring-offset-2
focus-visible:ring-offset-gray-900 transition"
>
{formState.status === 'sending' ? (
<span className="flex items-center justify-center gap-2">
<svg className="animate-spin h-5 w-5" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Sending...
</span>
) : (
'Send Message'
)}
</button>
{/* Status message */}
{formState.status === 'success' && (
<div role="alert" className="p-4 bg-green-900/30 border border-green-800 rounded-lg text-green-400 text-sm">
{formState.message}
</div>
)}
{formState.status === 'error' && (
<div role="alert" className="p-4 bg-red-900/30 border border-red-800 rounded-lg text-red-400 text-sm">
{formState.message}
</div>
)}
</form>
);
}
// Reusable form field component
function FormField({
label,
name,
type,
error,
required,
}: {
label: string;
name: string;
type: string;
error?: string;
required?: boolean;
}) {
return (
<div>
<label htmlFor={name} className="block text-sm font-medium text-gray-300 mb-1">
{label} {required && <span aria-hidden="true" className="text-red-400">*</span>}
</label>
<input
id={name}
name={name}
type={type}
required={required}
aria-required={required}
aria-invalid={!!error}
aria-describedby={error ? `${name}-error` : undefined}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-3
text-white placeholder:text-gray-500 focus:border-cyan-500
focus:ring-1 focus:ring-cyan-500 transition"
/>
{error && (
<p id={`${name}-error`} role="alert" className="text-sm text-red-400 mt-1">
{error}
</p>
)}
</div>
);
}
Approach 2: Next.js Server Actions
For more control, validation, and security, use Server Actions:
// app/contact/actions.ts
'use server';
import { z } from 'zod';
const contactSchema = z.object({
name: z.string().min(1, 'Name is required').max(100),
email: z.string().email('Invalid email address'),
message: z.string().min(10, 'Message must be at least 10 characters').max(5000),
honeypot: z.string().max(0), // Must be empty
});
export async function sendContactEmail(formData: FormData) {
const raw = {
name: formData.get('name') as string,
email: formData.get('email') as string,
message: formData.get('message') as string,
honeypot: formData.get('honeypot') as string || '',
};
// Validate with Zod
const result = contactSchema.safeParse(raw);
if (!result.success) {
return {
success: false,
errors: result.error.flatten().fieldErrors,
};
}
// Reject honeypot submissions
if (result.data.honeypot) {
// Silently succeed to not alert bots
return { success: true };
}
try {
// Option A: Send via Resend
const response = await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.RESEND_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
from: 'Portfolio Contact <noreply@yourdomain.com>',
to: process.env.CONTACT_EMAIL,
subject: `New message from ${result.data.name}`,
html: `
<h2>New Contact Form Submission</h2>
<p><strong>Name:</strong> ${escapeHtml(result.data.name)}</p>
<p><strong>Email:</strong> ${escapeHtml(result.data.email)}</p>
<p><strong>Message:</strong></p>
<p>${escapeHtml(result.data.message)}</p>
`,
reply_to: result.data.email,
}),
});
if (!response.ok) throw new Error('Email API error');
return { success: true };
} catch (error) {
console.error('Contact form error:', error);
return {
success: false,
errors: { form: ['Failed to send message. Please try again.'] },
};
}
}
function escapeHtml(text: string): string {
const map: Record<string, string> = {
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
};
return text.replace(/[&<>"']/g, (m) => map[m]);
}
Using Server Actions in a Form
'use client';
import { useActionState } from 'react';
import { sendContactEmail } from './actions';
export function ContactFormServerAction() {
const [state, formAction, isPending] = useActionState(
async (_prevState: unknown, formData: FormData) => {
return sendContactEmail(formData);
},
null
);
return (
<form action={formAction} className="space-y-4">
<input type="text" name="honeypot" className="hidden" tabIndex={-1} />
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-300 mb-1">
Name
</label>
<input
id="name"
name="name"
required
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-3 text-white"
/>
{state?.errors?.name && (
<p role="alert" className="text-sm text-red-400 mt-1">{state.errors.name[0]}</p>
)}
</div>
{/* Email and Message fields similar to above */}
<button
type="submit"
disabled={isPending}
className="w-full py-3 bg-cyan-600 text-white rounded-lg font-medium
hover:bg-cyan-700 disabled:opacity-50 transition"
>
{isPending ? 'Sending...' : 'Send Message'}
</button>
{state?.success && (
<div role="alert" className="p-4 bg-green-900/30 border border-green-800 rounded-lg text-green-400">
Message sent successfully!
</div>
)}
</form>
);
}
Spam Protection Strategies
1. Honeypot Fields (Already Shown Above)
Hidden fields that bots fill but humans don't. Simple and effective for basic spam.
2. Time-Based Validation
// Reject submissions faster than 3 seconds (bots are fast)
const FORM_LOAD_KEY = 'form_loaded_at';
// On form mount
useEffect(() => {
sessionStorage.setItem(FORM_LOAD_KEY, Date.now().toString());
}, []);
// On submit
const loadedAt = parseInt(sessionStorage.getItem(FORM_LOAD_KEY) || '0');
if (Date.now() - loadedAt < 3000) {
return; // Too fast, likely a bot
}
3. Rate Limiting
// Server-side rate limiting (Server Action or API route)
const submissions = new Map<string, number[]>();
function isRateLimited(ip: string, maxPerHour = 5): boolean {
const now = Date.now();
const hourAgo = now - 60 * 60 * 1000;
const times = (submissions.get(ip) || []).filter(t => t > hourAgo);
submissions.set(ip, [...times, now]);
return times.length >= maxPerHour;
}
EmailJS vs Server Actions: When to Use Which
| Factor | EmailJS | Server Actions |
|---|---|---|
| Setup time | 5 minutes | 30 minutes |
| Backend needed | No | Yes (Next.js) |
| Validation | Client-only | Server + client |
| Security | Public key exposed | API keys server-side |
| Rate limiting | EmailJS handles it | You implement it |
| Email customization | Template-based | Full HTML control |
| Cost | Free tier: 200/month | Depends on email provider |
| Best for | Portfolios, landing pages | SaaS, business apps |
Key Takeaways
- EmailJS for simplicity — Perfect for portfolios and static sites
- Server Actions for control — Full validation, security, and customization
- Honeypot + time check — Simple spam protection that catches 95% of bots
- Accessible error states —
aria-invalid,aria-describedby, androle="alert" - Escape HTML in emails — Prevent XSS in email templates
- Rate limit everything — Protect against abuse on both client and server
A contact form is often the most important conversion point on your site. Make it reliable, accessible, and spam-resistant, and visitors become clients.