Building in Public#building-in-public

Building a Real Contact Form API in Next.js

By Mohamed Abdi Guled

ShabelleHub launched with a contact form that looked perfect. One problem: when you submitted it, nothing happened.

Building a Real Contact Form API in Next.js

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 fetch call replaces the fake setTimeout
  • res.ok check 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.*

*Next: Why Fake AggregateRating Schema Hurts Your SEO*

📬 Get the latest AI tool reviews

Expert picks and comparisons, weekly. No spam.

← Back to Blog