Tutorials

8 common mistakes developers make with contact forms

February 14, 20267 min read
Common mistakes developers make with contact forms

Most contact forms work in the narrow sense: you can fill them in, click submit, and something happens. But there's a gap between "works when I test it" and "works reliably in production, handles edge cases, and protects against abuse."

These are the gaps that show up most often. Some are security issues. Some are UX problems. Some are just things that seem fine until they aren't. All of them are worth checking before you ship.

1. Using mailto: as the form action

<!-- Don't do this -->
<form action="mailto:you@yoursite.com" method="POST"></form>

This opens the visitor's email client with the form data pre-filled as a draft. It requires the visitor to have a configured desktop email client, which most people don't. On mobile, it often opens a mail app that's empty or disconnected. You get no record of what was submitted. It looks like something from 1998.

It's tempting because it seems like the simplest path. In practice, most visitors who use mailto: forms simply don't complete the submission. You're losing contact form leads silently.

The fix: point your form at a real endpoint, whether your own or a form backend service. The HTML contact form guide covers the full setup.

2. Relying on client-side validation only

Client-side validation is good UX. It catches mistakes immediately, without a round trip. But it's not a security layer.

Anyone can bypass your frontend validation with curl:

curl -X POST https://yoursite.com/contact \
  -F "email=notanemail" \
  -F "message=<script>alert(1)</script>"

Your JavaScript validation didn't run. Your required field checks didn't run. Whatever's in that message field goes straight to your handler.

Server-side validation needs to independently verify every constraint: required fields, format checks, length limits. Not as a fallback for bad UX, but because it's the only validation that actually provides security.

// This needs to be in your server-side handler, not just your frontend
function validate(data: FormData): string | null {
  const email = String(data.get("email") ?? "").trim();
  if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
    return "Valid email is required.";
  }
  const message = String(data.get("message") ?? "").trim();
  if (!message || message.length > 10_000) {
    return "Message is required and must be under 10,000 characters.";
  }
  return null;
}

3. No server-side rate limiting

Without rate limiting, your form endpoint accepts unlimited submissions from any IP. A bot can submit thousands of times per minute, filling your storage with junk, triggering thousands of notification emails, and potentially exhausting your hosting quota.

Rate limiting is a few lines of code and should be standard on any public endpoint:

// 5 submissions per IP per hour
const { success } = await ratelimit.limit(ip);
if (!success) {
  return new Response("Too many requests", { status: 429 });
}

The details and a fuller implementation are in The hidden complexity of handling form submissions.

4. Hiding honeypot fields with type="hidden"

This one is subtle. A honeypot field is meant to trap bots that fill every visible input. The key word is "visible": a honeypot only works if real users can't see it and bots can.

<!-- Wrong: bots know to skip type="hidden" fields -->
<input type="hidden" name="_honeypot" />

<!-- Right: hidden with CSS, looks like a real input to a bot's parser -->
<input
  name="_honeypot"
  type="text"
  tabindex="-1"
  autocomplete="off"
  style="position: absolute; left: -9999px;"
/>

When you use type="hidden", the field is explicitly marked as non-user-facing in the HTML spec. Bots are built to recognize this and skip it. A CSS-hidden field looks like any other text input to a bot's HTML parser and gets filled. That's what you want.

5. Using email as the only record of submissions

If your form sends a notification email but doesn't store submissions in a database, email delivery failure means lost data.

Email is not a reliable data store. Providers go down. Messages get filtered as spam. Configuration drifts. The one submission from your most promising prospect might arrive at 3am when your email client has a sync issue.

Always store submissions before sending notifications. The database is your source of truth. Email is a notification layer on top of it.

// Store first
const submission = await db.insert(submissions).values(fields).returning();

// Then notify (failure here doesn't lose the data)
try {
  await sendNotificationEmail(submission[0]);
} catch (err) {
  console.error("Notification failed:", err);
  // Log it, track it, but don't lose the submission
}

The deeper explanation of why this ordering matters is in the email notifications guide.

6. Using 302 redirect after form submission

After a successful POST, the conventional response is a redirect to a success page. Most developers use a 302 redirect. The correct status is 303.

Here's the difference: a 302 redirect technically means "temporarily moved." A 303 redirect means "see other, and use GET for that request." When a user hits the browser's back button after a 302 redirect, some browsers re-submit the POST request (because 302 preserves the original method). A 303 redirect switches to GET, which is safe to revisit.

// Wrong
return Response.redirect("https://yoursite.com/thank-you", 302);

// Right
return Response.redirect("https://yoursite.com/thank-you", 303);

A 302 after form submission is why some users see "Confirm Form Resubmission" dialogs when using the back button. The fix is one character.

7. Vague or absent error messages

When a form submission fails, the most common response is a generic "Something went wrong. Please try again." That's better than nothing, but it's not helpful when:

  • The user's email address was invalid and they don't know which field to fix
  • The submission was rate-limited and they don't know to wait a few minutes
  • The server was temporarily unavailable and they should retry immediately

Different failures warrant different messages. Distinguish between validation errors (user's fault, fixable immediately), rate limit errors (user's fault, fixable with time), and server errors (your fault, tell them to try again).

if (validationError) {
  return Response.json({ error: validationError }, { status: 400 });
}

if (rateLimited) {
  return Response.json(
    { error: "Too many submissions. Please wait a few minutes and try again." },
    { status: 429 }
  );
}

// Server error: be vague externally, but log the specifics
console.error("Unexpected error:", err);
return Response.json(
  { error: "Something went wrong on our end. Please try again." },
  { status: 500 }
);

On the frontend, display field-level errors next to the relevant input when you can. "Please enter a valid email address" next to the email field is more immediately useful than the same message at the top of the form.

8. Missing or broken label elements

Form accessibility is easy to get wrong because missing labels don't cause visible errors. The form still renders. Users can still fill it in. But screen reader users can't tell what each field is for, and the form fails accessibility audits.

<!-- Wrong: no label, relies on placeholder -->
<input type="email" placeholder="Email address" />

<!-- Wrong: label exists but isn't connected to the input -->
<label>Email address</label>
<input type="email" />

<!-- Right: label is connected via for/id -->
<label for="email">Email address</label>
<input id="email" name="email" type="email" />

Placeholder text disappears when you start typing, which means users who forget what they typed in a field have to clear it to see the hint again. Labels stay visible. They also expand the clickable area for the field on mobile, which is a usability win regardless of accessibility.

This is one of the cheaper improvements you can make to a form. Adding for/id pairs takes thirty seconds and makes the form usable for a wider range of people.


Most of these mistakes share a root: forms get built quickly and then not revisited. They work in the happy path and they ship. The edge cases, the security gaps, and the accessibility issues only show up later, sometimes much later.

A useful habit: before marking a form done, run through this list. Each item takes a few minutes to check, and a couple of them (rate limiting, storage) save real headaches down the road.

For a deeper look at what production-ready form handling actually involves, see The hidden complexity of handling form submissions. For securing your endpoint specifically, see Best ways to secure a form endpoint.

Want a form that handles all of this by default?

Formtorch validates, stores, rate-limits, and filters spam on every submission out of the box.

Related posts