Build a Contact Form with AI: A Working Form That Actually Sends Emails

Your portfolio looks great, but nobody can reach you. A contact form seems simple until you realize AI gives you a form that doesn't actually send anything. This tutorial builds a professional contact form with real email delivery, input validation, spam protection, and success feedback — everything AI forgets the first time.

TL;DR

You'll build a contact form that actually sends emails using Formspree's free tier. The form includes client-side validation, a honeypot spam trap, loading states, and success/error feedback. Stack: HTML + CSS + vanilla JavaScript + Formspree. No backend, no server, no framework. Time: ~45 minutes.

Why AI Coders Need to Know This

Every website needs a way for people to get in touch. Portfolio sites, business pages, landing pages — they all need a contact form. And every AI coder hits the same wall: you ask your AI to "add a contact form," and it generates a beautiful form that does absolutely nothing when you click submit.

That's because a contact form has two parts. The visible part — the fields, the button, the layout — is just HTML and CSS. AI is great at that. But the invisible part — actually receiving the submission and sending it to your email — requires a backend service. And AI almost never sets that up for you automatically.

Understanding how contact forms work is essential because:

  • It's your first real form-to-backend connection — the pattern you'll reuse for login forms, payment forms, signup flows, and every other form on the web
  • Validation matters more than you think — a form without validation lets people submit garbage, empty fields, or malicious code
  • Spam will bury you — an unprotected contact form on a live website gets dozens of bot submissions per day within weeks
  • User feedback is non-negotiable — if someone submits a form and nothing visually happens, they'll submit it five more times or just leave

This project teaches you the complete flow: what the user sees, what happens behind the scenes, and everything that can go wrong in between. By the end, you'll have a production-ready contact form you can drop into any project.

Real Scenario: What Happens When You Ask AI to Build a Contact Form

Here's what actually happens when you open Cursor, Windsurf, or Claude Code and type: "Build me a contact form for my portfolio website."

The AI will generate a perfectly styled form. It'll have name, email, and message fields. A submit button. Maybe even some nice CSS animations. It'll look professional.

Then you deploy it, someone fills it out, clicks submit, and... nothing. The page refreshes. Or a 404 error appears. Or the form just sits there. No email ever arrives in your inbox.

Why? Because AI typically generates one of these broken patterns:

  • Form action="#" — the form submits to itself, which just refreshes the page
  • Form action="" — same problem, just a different flavor of nowhere
  • JavaScript alert("Thank you!") — shows a popup but never sends the data anywhere
  • A mailto: link — opens the user's email client instead of sending the form data, which is jarring and unreliable
  • Backend code you can't run — AI writes a Node.js or Python server to handle the form, but you're on a static host with no server

The fix is simple: use a form service like Formspree that gives you a real endpoint to submit to. But you also need validation, spam protection, and proper user feedback — none of which AI includes by default.

Let's build the complete solution.

What AI Generated: The Complete Contact Form

Here's the prompt that actually works, followed by the code you should end up with. If your AI gives you something different, compare it against this and fill in the gaps.

Cursor / Claude Code Prompt

"Build a contact form with name, email, and message fields. Use Formspree for form handling — submit via JavaScript fetch to https://formspree.io/f/YOUR_FORM_ID. Include: (1) client-side validation for all fields before submission, (2) email format validation, (3) a honeypot field for spam protection, (4) a loading state on the submit button, (5) success and error messages that appear after submission, (6) the form resets after successful submission. Style it professionally with CSS — clean, modern, mobile-responsive."

The HTML

<!-- Contact Form with Formspree Integration -->
<section class="contact-section" id="contact">
  <div class="contact-container">
    <h2>Get in Touch</h2>
    <p class="contact-intro">Have a question or want to work together? 
       Send me a message and I'll get back to you within 24 hours.</p>

    <form id="contact-form" action="https://formspree.io/f/YOUR_FORM_ID" 
          method="POST" novalidate>

      <!-- Honeypot field — hidden from humans, catches bots -->
      <input type="text" name="_gotcha" style="display:none" 
             tabindex="-1" autocomplete="off">

      <div class="form-group">
        <label for="name">Name</label>
        <input type="text" id="name" name="name" 
               placeholder="Your name" required 
               minlength="2" maxlength="100">
        <span class="error-message" id="name-error"></span>
      </div>

      <div class="form-group">
        <label for="email">Email</label>
        <input type="email" id="email" name="email" 
               placeholder="your@email.com" required>
        <span class="error-message" id="email-error"></span>
      </div>

      <div class="form-group">
        <label for="message">Message</label>
        <textarea id="message" name="message" rows="5" 
                  placeholder="What's on your mind?" required 
                  minlength="10" maxlength="5000"></textarea>
        <span class="error-message" id="message-error"></span>
      </div>

      <button type="submit" id="submit-btn" class="submit-button">
        <span class="btn-text">Send Message</span>
        <span class="btn-loading" style="display:none">Sending...</span>
      </button>

      <div id="form-status" class="form-status" role="alert"></div>
    </form>
  </div>
</section>

The CSS

/* Contact Form Styles */
.contact-section {
  max-width: 600px;
  margin: 2rem auto;
  padding: 0 1rem;
}

.contact-container {
  background: #ffffff;
  border-radius: 12px;
  padding: 2.5rem;
  box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
}

.contact-container h2 {
  font-size: 1.75rem;
  margin-bottom: 0.5rem;
  color: #1a1a2e;
}

.contact-intro {
  color: #6b7280;
  margin-bottom: 2rem;
  line-height: 1.6;
}

.form-group {
  margin-bottom: 1.5rem;
}

.form-group label {
  display: block;
  font-weight: 600;
  margin-bottom: 0.5rem;
  color: #1a1a2e;
  font-size: 0.9rem;
}

.form-group input,
.form-group textarea {
  width: 100%;
  padding: 0.75rem 1rem;
  border: 2px solid #e5e7eb;
  border-radius: 8px;
  font-size: 1rem;
  font-family: 'Inter', sans-serif;
  transition: border-color 0.2s ease;
  box-sizing: border-box;
}

.form-group input:focus,
.form-group textarea:focus {
  outline: none;
  border-color: #6366f1;
  box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.1);
}

.form-group input.invalid,
.form-group textarea.invalid {
  border-color: #ef4444;
}

.error-message {
  display: block;
  color: #ef4444;
  font-size: 0.8rem;
  margin-top: 0.25rem;
  min-height: 1.2em;
}

.submit-button {
  width: 100%;
  padding: 0.875rem;
  background: #6366f1;
  color: white;
  border: none;
  border-radius: 8px;
  font-size: 1rem;
  font-weight: 600;
  cursor: pointer;
  transition: background 0.2s ease, transform 0.1s ease;
}

.submit-button:hover {
  background: #4f46e5;
  transform: translateY(-1px);
}

.submit-button:disabled {
  background: #9ca3af;
  cursor: not-allowed;
  transform: none;
}

.form-status {
  margin-top: 1rem;
  padding: 0;
  border-radius: 8px;
  text-align: center;
  font-weight: 500;
  transition: all 0.3s ease;
}

.form-status.success {
  padding: 1rem;
  background: #ecfdf5;
  color: #065f46;
  border: 1px solid #6ee7b7;
}

.form-status.error {
  padding: 1rem;
  background: #fef2f2;
  color: #991b1b;
  border: 1px solid #fca5a5;
}

/* Responsive */
@media (max-width: 640px) {
  .contact-container {
    padding: 1.5rem;
  }
}

The JavaScript

// Contact Form Handler
const form = document.getElementById('contact-form');
const submitBtn = document.getElementById('submit-btn');
const btnText = submitBtn.querySelector('.btn-text');
const btnLoading = submitBtn.querySelector('.btn-loading');
const formStatus = document.getElementById('form-status');

// Validation functions
function validateName(name) {
  if (!name.trim()) return 'Name is required';
  if (name.trim().length < 2) return 'Name must be at least 2 characters';
  return '';
}

function validateEmail(email) {
  if (!email.trim()) return 'Email is required';
  // Simple but effective email pattern
  const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  if (!emailPattern.test(email)) return 'Please enter a valid email address';
  return '';
}

function validateMessage(message) {
  if (!message.trim()) return 'Message is required';
  if (message.trim().length < 10) return 'Message must be at least 10 characters';
  return '';
}

function showError(fieldId, message) {
  const field = document.getElementById(fieldId);
  const errorSpan = document.getElementById(fieldId + '-error');
  if (message) {
    field.classList.add('invalid');
    errorSpan.textContent = message;
  } else {
    field.classList.remove('invalid');
    errorSpan.textContent = '';
  }
}

// Real-time validation as user types
['name', 'email', 'message'].forEach(fieldId => {
  const field = document.getElementById(fieldId);
  field.addEventListener('blur', () => {
    const validators = { name: validateName, email: validateEmail, message: validateMessage };
    showError(fieldId, validators[fieldId](field.value));
  });
  // Clear error when user starts fixing it
  field.addEventListener('input', () => {
    if (field.classList.contains('invalid')) {
      const validators = { name: validateName, email: validateEmail, message: validateMessage };
      showError(fieldId, validators[fieldId](field.value));
    }
  });
});

// Form submission
form.addEventListener('submit', async (e) => {
  e.preventDefault();

  // Validate all fields
  const nameError = validateName(document.getElementById('name').value);
  const emailError = validateEmail(document.getElementById('email').value);
  const messageError = validateMessage(document.getElementById('message').value);

  showError('name', nameError);
  showError('email', emailError);
  showError('message', messageError);

  // Stop if any validation errors
  if (nameError || emailError || messageError) {
    // Focus the first field with an error
    if (nameError) document.getElementById('name').focus();
    else if (emailError) document.getElementById('email').focus();
    else document.getElementById('message').focus();
    return;
  }

  // Show loading state
  submitBtn.disabled = true;
  btnText.style.display = 'none';
  btnLoading.style.display = 'inline';
  formStatus.className = 'form-status';
  formStatus.textContent = '';

  try {
    const formData = new FormData(form);
    const response = await fetch(form.action, {
      method: 'POST',
      body: formData,
      headers: { 'Accept': 'application/json' }
    });

    if (response.ok) {
      formStatus.className = 'form-status success';
      formStatus.textContent = 'Message sent! I\'ll get back to you soon.';
      form.reset();
    } else {
      const data = await response.json();
      throw new Error(data.error || 'Something went wrong');
    }
  } catch (error) {
    formStatus.className = 'form-status error';
    formStatus.textContent = 'Oops! Something went wrong. Please try again or email me directly.';
  } finally {
    submitBtn.disabled = false;
    btnText.style.display = 'inline';
    btnLoading.style.display = 'none';
  }
});

Understanding Each Part

Let's break down what every piece of this code does and why it's there. You don't need to memorize the syntax — you need to understand what each part accomplishes so you can tell when your AI misses something.

The Formspree Integration

The most critical line in the entire form is this one:

<form action="https://formspree.io/f/YOUR_FORM_ID" method="POST">

This tells the browser: "When someone submits this form, send the data to Formspree." Formspree receives it, checks for spam, and forwards it to your email. You get this endpoint by creating a free account at formspree.io and setting up a form — takes about 2 minutes. The free tier gives you 50 submissions per month, which is plenty for a portfolio site.

This is the part AI almost always gets wrong. It'll generate the visual form but skip the actual delivery mechanism. Without this action URL pointing to a real service, your form is decorative — it looks like it works but doesn't go anywhere. Think of it like installing a mailbox on your house but never connecting it to the postal route.

The Honeypot Spam Trap

<input type="text" name="_gotcha" style="display:none" tabindex="-1">

This is a hidden field. Humans never see it, so they never fill it out. But spam bots scan the page code and fill in every field they find. Formspree checks: if _gotcha has a value, it knows the submission came from a bot and silently discards it.

It's the simplest form of spam protection and works surprisingly well. For higher-traffic sites, you'd also add reCAPTCHA, but a honeypot handles 80–90% of automated spam.

The novalidate Attribute

<form ... novalidate>

This tells the browser: "Don't use your built-in validation popups — I'm handling validation myself with JavaScript." Why? Because browser-native validation looks different on every browser and gives minimal, non-customizable error messages. Custom validation lets you show clear, styled error messages right where the user needs them.

Client-Side Validation

Each field has a validation function that checks for specific problems:

  • Name: Can't be empty, must be at least 2 characters
  • Email: Can't be empty, must match a basic email pattern (something@something.something)
  • Message: Can't be empty, must be at least 10 characters (prevents "hi" submissions)

Validation runs in two places: when the user clicks away from a field (blur event) and when they submit the form. This gives immediate feedback without being annoying — it doesn't yell at you while you're still typing.

The Loading State

When the form submits, the button text changes from "Send Message" to "Sending..." and the button becomes disabled. This prevents double-submissions and tells the user something is happening. Without this, people click the submit button repeatedly, sending you 5 copies of the same message.

Success and Error Feedback

After submission, a colored status box appears:

  • Green (success): "Message sent! I'll get back to you soon." — the form also resets so they know it went through
  • Red (error): "Oops! Something went wrong. Please try again or email me directly." — always give an alternative contact method

The role="alert" on the status div makes screen readers announce the message, which matters for accessibility.

The Fetch API Call

const response = await fetch(form.action, {
  method: 'POST',
  body: formData,
  headers: { 'Accept': 'application/json' }
});

This sends the form data to Formspree via JavaScript instead of a traditional form submission. The advantage? The page doesn't reload. The user stays on your site, sees the success message, and can keep browsing. The Accept: application/json header tells Formspree to send back a JSON response instead of redirecting to their default "thank you" page. This is how modern forms work — the same pattern you'll see in every API interaction.

Setting Up Formspree (5 Minutes)

Before your form can send emails, you need a Formspree endpoint. Here's the quick setup:

  1. Go to formspree.io and create a free account
  2. Click "New Form" and give it a name (like "Portfolio Contact")
  3. Formspree gives you an endpoint URL: https://formspree.io/f/xyzabc123
  4. Replace YOUR_FORM_ID in the HTML with your actual form ID
  5. Confirm your email address when Formspree sends a verification email
  6. Submit a test message — check your inbox

That's it. No server setup, no environment variables, no deployment pipeline. The form is live as soon as you deploy the HTML file. If you want to understand more about what's happening between your form and Formspree's servers, check out our article on what APIs are and why they matter.

What AI Gets Wrong: Common Mistakes with Contact Forms

After building dozens of contact forms with AI tools, here are the mistakes that show up almost every time. Knowing these in advance saves you hours of debugging.

Mistake 1: No Backend Integration

This is the big one. AI generates action="#" or action="" on the form, which means the data goes nowhere. Sometimes it writes a JavaScript alert() that pops up "Thank you!" without actually sending anything. The form looks like it works. It doesn't.

The fix: Always check the action attribute. It must point to a real URL — either a form service like Formspree or your own server endpoint.

Mistake 2: No Input Validation

AI often relies entirely on HTML required attributes and skips JavaScript validation. This means: no email format checking, no minimum message length, no custom error messages — just the browser's generic "Please fill out this field" popup. Worse, HTML-only validation is trivially bypassed.

The fix: Add the novalidate attribute to the form and write JavaScript validation functions for each field. Validate on blur (when the user leaves a field) and on submit.

Mistake 3: No Spam Protection

AI almost never adds spam protection. Within days of deploying an unprotected form on a live site, you'll start getting bot submissions — fake messages, phishing links, and automated junk. It gets worse over time as bots discover the form.

The fix: Add a honeypot field at minimum. For higher-traffic sites, add reCAPTCHA or use a form service with built-in spam filtering (Formspree's filtering catches most bots automatically).

Mistake 4: No Success or Error Feedback

The user clicks submit and... nothing visible happens. The form clears (maybe), but there's no confirmation that the message was sent and no error message if it failed. Users don't know if they should try again or wait.

The fix: Show a clear, visible success message after successful submission. Show an error message with an alternative contact method if submission fails. Use role="alert" for accessibility.

Mistake 5: Page Redirect on Submit

If you let the form submit normally (without JavaScript fetch), the browser navigates away from your site — either to a blank page, a Formspree "thank you" page, or an error. The user is gone from your site.

The fix: Use e.preventDefault() in the submit handler and send the data via fetch(). This keeps the user on your page and lets you control what they see next.

Mistake 6: No Loading State

Network requests take time. If the button stays clickable and nothing changes during submission, users click it repeatedly. You get duplicate messages. Or they think it's broken and leave.

The fix: Disable the button and change its text to "Sending..." during the request. Re-enable it when the request completes (success or failure).

How to Debug with AI

When your contact form isn't working, here's how to use your AI coding tools to find and fix the problem fast.

Cursor

Open the file with your form and use Cmd+K (or Ctrl+K on Windows). Try these prompts:

  • "This form isn't sending emails. Check the form action URL and make sure it's submitting via fetch with the correct headers."
  • "Add real-time validation to this form — validate on blur, show inline error messages, clear errors when the user fixes the input."
  • "My form submits but the page refreshes instead of showing a success message. Fix the JavaScript to prevent default and show feedback."

Cursor's inline editing makes it easy to iterate on individual validation functions without regenerating the whole form.

Windsurf

Use Windsurf's Cascade feature for multi-file debugging:

  • "Check my contact form setup — verify the HTML form action, the JavaScript fetch call, the validation logic, and the CSS for error states. Flag anything that looks broken or missing."
  • "Add accessibility improvements to my contact form: proper aria labels, focus management after submission, keyboard navigation, and screen reader announcements."

Claude Code

Claude Code is great for understanding why something isn't working:

  • "Look at my contact form code. Walk me through exactly what happens when a user clicks submit — every step, including what could go wrong at each point."
  • "I'm getting CORS errors when my form submits. Explain what CORS is and why my form is hitting this issue. Then fix it."
  • "Add a honeypot field and explain how it blocks spam bots."

Quick Debug Checklist

Before asking AI for help, check these yourself:

  1. Open browser DevTools → Network tab — submit the form and watch for the request. Is it actually sending? What's the response status code?
  2. Check the Console tab — are there JavaScript errors preventing the form handler from running?
  3. Verify your Formspree endpoint — is the URL correct? Did you confirm your email with Formspree?
  4. Test with a simple form first — strip out all JavaScript and submit a basic HTML form directly to Formspree. If that works, the problem is in your JavaScript.
  5. Check for CORS issues — if you see "Access-Control-Allow-Origin" errors, you might be testing from a file:// URL. Use a local dev server instead (npx serve or VS Code Live Server).

Making It Production-Ready

The code above works, but here are a few improvements for a form you're putting on a real site:

Add Character Count to the Message Field

Tell AI: "Add a character counter below the message textarea showing 'X / 5000 characters.' Update it as the user types." This helps users know the limit before they hit it.

Add a Subject Field (Optional)

If you're using the form for business inquiries, adding a subject or "What's this about?" dropdown helps you sort incoming messages. Tell AI: "Add a select dropdown for 'Topic' with options: General Inquiry, Project Collaboration, Bug Report, Other."

Style It to Match Your Site

The CSS above uses a clean white card design. To match your portfolio site's existing design, tell AI: "Restyle the contact form to match the color scheme and typography of my portfolio page" and include your portfolio's CSS file in the context.

Add Rate Limiting

Prevent users from accidentally (or intentionally) submitting dozens of times. Tell AI: "After a successful submission, disable the form for 60 seconds and show a countdown timer." Formspree also has its own rate limits on the free tier.

What to Learn Next

Now that you've built a working contact form, you understand the complete cycle: user input → validation → submission → backend processing → feedback. That's the foundation of every form on the web. Here's where to go next:

You've now built something that actually does something — collects real data and delivers it to your inbox. That's a real step up from static HTML pages. Every SaaS app, e-commerce site, and web tool you'll build next uses this exact pattern: form → validate → send → respond. You just learned it.

Frequently Asked Questions

Do I need a backend server to make a contact form work?

No. Services like Formspree, Getform, and Web3Forms handle the backend for you. You point your form's action attribute to their endpoint, and they process the submission and forward it to your email. Free tiers typically allow 50–100 submissions per month, which is plenty for a portfolio or small business site.

Why does my AI-generated contact form not send emails?

The most common reason is that AI generates a form with no backend integration — just an HTML form that submits to nowhere. Check that your form's action attribute points to a real URL (like https://formspree.io/f/yourID), not just # or an empty string. Also verify you've confirmed your email address with the form service.

How do I prevent spam on my contact form?

Start with a honeypot field — a hidden input that bots fill out but humans never see. If it's filled in, the submission is from a bot and gets discarded. For more protection, add reCAPTCHA or use a form service with built-in spam filtering. Formspree includes spam filtering on all plans, including free.

What's the difference between client-side and server-side validation?

Client-side validation (JavaScript in the browser) gives instant feedback — "Please enter a valid email" — before the form is submitted. Server-side validation checks the data again after submission. You need both: client-side for user experience, server-side for security. AI often generates only client-side validation, which can be bypassed by anyone with browser dev tools.

Can I add a contact form to a static site hosted on Vercel or Netlify?

Yes. Static sites can use third-party form services like Formspree, Getform, or Netlify Forms (which is built in if you're already on Netlify). These services provide an API endpoint your form submits to, so you don't need to write or host any server-side code. Vercel users typically use Formspree or build a small serverless function.