Engineering

The hidden complexity of handling form submissions

December 8, 20257 min read
The hidden complexity of handling form submissions

A contact form looks like the simplest thing in web development. A few inputs, a button, maybe a nice loading state. You could prototype it in ten minutes.

Then you try to make it actually work in production.

Not "works when I test it on my machine" production. Actually works: survives real users, real bots, real email delivery failures, real edge cases. That version takes considerably longer.

Here's a complete map of what production-ready form handling actually involves. If you're building a custom backend, this is what you're signing up for. If you're evaluating whether to use a service, this is what you'd be delegating.

The visible part

The part everyone thinks about: the HTML form or React component. A name field, an email field, a message, a submit button. Maybe some client-side validation.

This part is legitimately easy. It's also, roughly, 10% of the actual work.

CORS

If your frontend and your form endpoint live on different domains (which they almost always do when you're submitting from a static site to a serverless function or a third-party service), you'll hit CORS.

CORS is the browser's way of asking your server: "is this other site allowed to send requests to you?" Your server responds with headers that either permit or deny the request. If the headers are wrong, the browser blocks the submission before it even goes out.

// In your form handler, you'll need headers like these:
return new Response(JSON.stringify({ ok: true }), {
  headers: {
    "Access-Control-Allow-Origin": "https://yoursite.com",
    "Access-Control-Allow-Methods": "POST, OPTIONS",
    "Access-Control-Allow-Headers": "Content-Type",
  },
});

And before a POST, the browser sends a preflight OPTIONS request to check permissions. You have to handle that too, or every real submission gets blocked.

This catches people off guard because forms work fine when you're testing locally on the same origin. It breaks in production.

Server-side validation

Client-side validation is good UX. It catches honest mistakes before the user ever leaves your page. But it is not, and cannot be, a security mechanism.

Anyone can open the browser console, disable your JavaScript, and submit your form directly with curl. Or just craft a raw POST request. Client-side validation doesn't run in those cases.

Server-side validation means re-checking every constraint on the server: required fields, valid email formats, message length limits, file type restrictions. You need this regardless of what you do on the frontend.

function validate(data: FormData): string | null {
  const email = data.get("email");
  const message = data.get("message");

  if (!email || typeof email !== "string") return "Email is required.";
  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return "Invalid email.";
  if (!message || typeof message !== "string") return "Message is required.";
  if (message.length > 5000) return "Message is too long.";

  return null;
}

This is table stakes. If you skip it, your endpoint will accept arbitrary garbage.

Spam filtering

Every publicly accessible form endpoint gets hit by bots. It's not a matter of if, it's when, and for active sites, it's within hours of going live.

Useful spam filtering involves at least three layers working together:

Honeypot fields: a hidden input that real users never fill but bots usually do. If a submission includes it, it's almost certainly spam.

Rate limiting: caps on submissions per IP address per time window. A real user submits once; a bot might submit thousands of times.

Content scoring: analyzing the submitted content for signals, keyword patterns, and behavioral anomalies, and assigning a spam score rather than a binary pass/fail.

Building all of this from scratch is a real project. Each component requires thought, testing, and ongoing maintenance as spam patterns evolve.

For a deep look at the techniques and why they work, see How to prevent spam in contact forms.

Data storage

Where do submissions go?

Email-only delivery is fragile. Email gets filtered, servers go down, addresses change. If you only send email and don't store the submission somewhere, you have no recovery path when delivery fails.

A proper setup stores every submission in a database before attempting delivery. The database is your source of truth. Email is a notification layer on top of it.

// Store first, then notify
const submissionId = await db.insert(submissions).values({
  formId,
  data: JSON.stringify(fields),
  ip: req.headers.get("x-forwarded-for"),
  createdAt: new Date(),
});

// If this fails, you still have the submission in the database
await sendNotificationEmail(submissionId);

The database also gives you a place to search submissions, export data, see submission history, and audit what's coming in. Without it, your form is a black box.

Email delivery

Sending email reliably from your own server is harder than it sounds. You need an outgoing mail server, proper SPF and DKIM DNS records, a sending domain with good reputation, and error handling for bounces and rejections.

Most developers use a transactional email API (Resend, SendGrid, Postmark) to handle the actual delivery. That's the right call. But you still need to handle the cases where delivery fails.

try {
  await resend.emails.send({
    from: "notifications@yoursite.com",
    to: "you@yoursite.com",
    subject: `New contact: ${submitterName}`,
    html: emailBody,
  });
  await db.update(submissions).set({ deliveryStatus: "delivered" }).where(...);
} catch (error) {
  await db.update(submissions).set({ deliveryStatus: "failed" }).where(...);
  // You want to know about this, but it shouldn't fail the whole request
}

Fire-and-forget works for notification emails because the submission is already stored. A delivery failure doesn't lose the submission; it just means you check your dashboard instead of your inbox.

Rate limiting

Different from spam filtering. Spam filtering looks at the content of a submission. Rate limiting looks at volume from a given source.

You need both. A bot can submit valid-looking content at high volume. Rate limiting catches that even when the content passes your spam filter.

const key = `ratelimit:${ip}:${formId}`;
const count = await redis.incr(key);
if (count === 1) await redis.expire(key, 3600); // 1 hour window

if (count > 5) {
  return new Response("Too many requests", { status: 429 });
}

This needs persistent storage (Redis, or a database table) to work across server instances. In-memory rate limiting only works for a single server process, which is fine locally but breaks as soon as you have multiple instances or serverless functions.

File upload handling

If your form accepts file uploads, the complexity jumps significantly.

You need to handle multipart form encoding, validate file types (checking magic bytes, not just the extension), limit file sizes, scan for malicious content, store the files somewhere durable (S3 or similar), and generate references to them that link to the submission record.

Then consider: what happens when someone uploads a 500MB video? What happens when they upload an executable disguised as a PDF? What happens when your storage is full?

File uploads are a form type that most people underestimate until they've been burned by it.

Redirects and response formats

HTML forms expect a redirect or an HTML response. JavaScript fetches expect JSON. If you get this wrong, you get confusing behavior: a fetch that works but returns a 301 redirect to a success page instead of a JSON confirmation.

const isAjax = req.headers.get("x-requested-with") === "XMLHttpRequest";

if (isAjax) {
  return Response.json({ ok: true });
} else {
  return Response.redirect("https://yoursite.com/thank-you", 303);
}

Handling both cases means your endpoint works with plain HTML forms (which don't execute JavaScript) and with React or JavaScript-based fetches.

The cumulative weight

Here's the thing: none of these problems are individually hard. CORS is a few headers. Validation is some conditionals. Rate limiting is a counter and a timer. Spam scoring is a point system.

But you need all of them at once, they interact with each other, and maintaining them over time adds up. When a new spam pattern emerges, you update the scoring. When a new email client breaks your HTML email templates, you fix them. When Redis goes down, your rate limiter needs a fallback. Each moving part is a potential failure mode.

This is why "just build a quick form backend" often turns into a multi-day project, and why form backends exist as a category of product.

What using a form service actually buys you

When you point your HTML form at a hosted endpoint, you're not just getting a place to send submissions. You're getting the entire stack above already built, tested, and maintained.

The HTML contact form guide and Next.js contact form guide on this blog cover how to integrate with a hosted endpoint. The integration is straightforward because you're skipping the entire list above.

That's not laziness. It's recognizing that form handling is a solved, well-understood problem with an established solution category. Spending two days building form infrastructure is two days not spent on whatever your actual product does.

Skip the infrastructure, ship the form.

The Formtorch form backend handles storage, spam filtering, email notifications, and rate limiting so you don't have to.

Related posts