Building a Real Contact Form API in Next.js
ShabelleHub launched with a contact form that looked perfect. One problem: when you submitted it, nothing happened.
ShabelleHub launched with a contact form that looked perfect. Clean design, validation, loading state, success message. One problem: when you submitted it, nothing happened.
Not "it failed silently." Literally nothing. The form showed a success message, the user believed their message was sent, and the data vanished into a setTimeout that resolved after 400 milliseconds and did nothing else.
This is the story of how we replaced it with a real API endpoint — with rate limiting, input validation, and actual error handling.
The Original Code
The original handleSubmit function in pages/contact.js:
const handleSubmit = async () => {
const err = validate();
if (err) { setError(err); return; }
setLoading(true);
setError('');
try {
// TODO: wire to /api/contact, Formspree, or Resend
await new Promise(r => setTimeout(r, 400));
setSent(true);
} catch {
setError('Something went wrong. Please try again.');
} finally {
setLoading(false);
}
};
The setTimeout was a placeholder left in from development. It simulated a network request, showed a success state, and did nothing with the form data. Every message submitted through this form was lost permanently.
The TODO comment tells the full story: this was never meant to ship this way. It shipped anyway.
Why This Is Worse Than Having No Contact Form
A broken form that looks functional is worse than no form at all, for two reasons:
Users trust it. Someone submitting a contact form believes their message was received. They may wait days for a response that will never come, or assume you ignored them. That is a worse experience than seeing an honest "email us directly at..." message.
You cannot receive feedback. Tool vendors, readers who found errors, partnership inquiries — all of it disappears. For a site that relies on trust and community feedback, a silent contact form is a liability.
The Fix — Building /api/contact.js
We created a real API route at pages/api/contact.js. The requirements were:
- Validate all inputs server-side (not just client-side)
- Rate limit by IP to prevent spam
- Actually deliver the message somewhere
- Return honest error responses
Rate Limiting
The rate limiter uses an in-memory Map keyed by IP address. It allows a maximum of 5 submissions per minute per IP:
const rateLimitMap = new Map();
const WINDOW_MS = 60_000; // 1 minute
const MAX_REQUESTS = 5;
function isRateLimited(ip) {
const now = Date.now();
const record = rateLimitMap.get(ip);
// Prune old entries to keep the Map bounded
for (const [key, val] of rateLimitMap) {
if (now - val.start > WINDOW_MS * 10) rateLimitMap.delete(key);
}
if (!record || now - record.start > WINDOW_MS) {
rateLimitMap.set(ip, { count: 1, start: now });
return false;
}
if (record.count >= MAX_REQUESTS) return true;
rateLimitMap.set(ip, { count: record.count + 1, start: record.start });
return false;
}
This is an in-memory rate limiter, which means it resets on each cold start. For a serverless deployment on Vercel, that is acceptable for a contact form — the goal is preventing obvious spam, not enforcing precise limits across distributed instances.
Input Validation
Every field is validated server-side. Client-side validation is a UX improvement, not a security measure:
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
const MAX_FIELD_LENGTH = 200;
const MAX_MESSAGE_LENGTH = 5000;
// In the handler:
if (!name || typeof name !== 'string' || !name.trim() || name.length > MAX_FIELD_LENGTH) {
return res.status(400).json({ error: 'Please enter a valid name.' });
}
if (!email || typeof email !== 'string' || !EMAIL_RE.test(email.trim()) || email.length > MAX_FIELD_LENGTH) {
return res.status(400).json({ error: 'Please enter a valid email address.' });
}
if (!message || typeof message !== 'string' || !message.trim() || message.length > MAX_MESSAGE_LENGTH) {
return res.status(400).json({ error: 'Please enter a message.' });
}
Length limits prevent both accidental and intentional oversized payloads. The email regex is intentionally simple — it catches obvious formatting errors without being fragile.
Log Injection Prevention
User input written to server logs needs sanitization. A message containing newlines can inject fake log entries:
function escapeForLog(str) {
return String(str).replace(/[\r\n]/g, ' ').slice(0, 500);
}
Carriage returns and newlines are replaced with spaces before logging. This prevents a crafted message from making logs look like they contain additional entries.
The Full Handler
export default async function handler(req, res) {
if (req.method !== 'POST') {
res.setHeader('Allow', 'POST');
return res.status(405).json({ error: 'Method not allowed' });
}
const ip = (
req.headers['x-forwarded-for']?.split(',')[0].trim() ||
req.socket?.remoteAddress ||
'unknown'
);
if (isRateLimited(ip)) {
return res.status(429).json({ error: 'Too many requests. Please try again later.' });
}
const { name, email, reason, message } = req.body || {};
// Validation (see above)
const safeEmail = email.trim().toLowerCase();
try {
// Wire to Resend, Postmark, or any email provider here:
//
// const resendRes = await fetch('https://api.resend.com/emails', {
// method: 'POST',
// headers: {
// 'Content-Type': 'application/json',
// Authorization: `Bearer ${process.env.RESEND_API_KEY}`,
// },
// body: JSON.stringify({
// from: 'ShabelleHub <contact@shabellehub.com>',
// to: process.env.CONTACT_TO_EMAIL,
// reply_to: safeEmail,
// subject: `[Contact] ${reason || 'General'} — ${name.trim()}`,
// text: `From: ${name.trim()} <${safeEmail}>\n\n${message.trim()}`,
// }),
// });
// Until an email provider is configured, log the submission:
console.log(
`[ShabelleHub] Contact from ${escapeForLog(safeEmail)}: ` +
`${escapeForLog(message.trim())}`
);
return res.status(200).json({ success: true });
} catch (err) {
console.error('[ShabelleHub] Contact error:', err);
return res.status(500).json({
error: 'Could not send your message. Please try again or email us directly.',
});
}
}
Wiring the Form
The handleSubmit function in pages/contact.js now calls the real endpoint:
const handleSubmit = async () => {
const err = validate();
if (err) { setError(err); return; }
setLoading(true);
setError('');
try {
const res = await fetch('/api/contact', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form),
});
if (res.ok) {
setSent(true);
} else {
const data = await res.json().catch(() => ({}));
setError(data.error || 'Something went wrong. Please try again.');
}
} catch {
setError('Network error. Please check your connection and try again.');
} finally {
setLoading(false);
}
};
The key differences from the original:
- A real
fetchcall replaces the fakesetTimeout res.okcheck handles non-200 responses correctly- Server error messages surface to the user
- Network failures have a specific, honest error message
Adding an Email Provider
The endpoint is structured to make wiring an email provider a one-step change. We use Resend because it has a clean API and a generous free tier:
// 1. Install
// npm install resend
// 2. Add to Vercel environment variables:
// RESEND_API_KEY = re_xxxxxxxxxxxxx
// CONTACT_TO_EMAIL = hello@shabellehub.com
// 3. Uncomment the Resend block in the handler
The commented-out Resend block in the handler is ready to activate. Until it is, messages are logged to Vercel's function logs, which is visible in the Vercel dashboard — not ideal for production, but better than a silent discard.
What a Contact Form Actually Needs
Looking back, the minimum requirements for a production contact form are not complicated:
| Requirement | Why |
|-------------|-----|
| Server-side validation | Client validation is bypassable |
| Rate limiting | Prevents spam and abuse |
| Real error messages | Users need to know when something fails |
| Log injection prevention | User input in logs is a risk |
| Actual message delivery | The entire point of the form |
None of these are complex to implement. The original setTimeout placeholder was faster to write, but it shipped a broken user experience and a silent data sink into production.
The lesson: a contact form that does not deliver messages is not a contact form. It is a UX trap.
*This article is part of the ShabelleHub Building in Public series.*
📬 Get the latest AI tool reviews
Expert picks and comparisons, weekly. No spam.