Simple Contact Form for Website: Build Yours with HTML &

Simple Contact Form for Website: Build Yours with HTML &

15 min read
Static Forms Team

You've got a static site deployed, the pages are fast, Lighthouse is clean, and then the annoying part shows up: people need a way to contact you. A mailto: link is easy, but it dumps the user into their local email client, exposes your address to scrapers, and gives you no control over validation, spam filtering, or post-submit UX.

A simple contact form for a website solves that, but only if you treat it like a production feature from the start. The hard part isn't the <form> tag. It's delivery, spam, confirmation, privacy, and making sure submissions land where your team can act on them.

Going Beyond a Basic Contact Page

For JAMstack and static sites, a contact form sits in an awkward spot. The frontend is simple. The backend requirement is tiny. But once you need email notifications, validation, and spam control, “tiny” turns into a maintenance burden fast if you build it yourself.

That's why form backends became a standard pattern for static sites. You keep plain HTML on the frontend, point the form at a hosted endpoint, and let that service handle submission processing, notifications, storage, webhooks, and anti-spam checks. The shape is simple: browser posts form data, provider validates it, then routes it to email, a dashboard, or another tool.

The UX side matters too. Users usually choose the path that feels quickest. In contact preference research, 67.3% of respondents chose email when a site offered both a contact form and an email address, which is a useful reminder that your form has to earn its place through speed and clarity, not just existence (Network Solutions contact form research).

Practical rule: If your form feels slower than sending an email, people will avoid it.

A decent production baseline looks like this:

  • Keep the form short: Ask for only what you need to reply.
  • Make delivery reliable: Don't trust browser success states alone.
  • Confirm submission: Show users what happened after they click submit.
  • Handle spam early: Public endpoints get abused.
  • Route submissions somewhere useful: Email inbox, Slack, Sheets, CRM, or webhook.

What doesn't work is the common fake-minimal setup: a nice-looking form with no anti-spam, no auto-response, no meaningful error handling, and no audit trail when a client says, “I sent this last week.”

That setup looks done on launch day. It breaks on day two.

Building Your HTML Form Foundation

The markup should be boring. That's a good thing. A contact form isn't the place for clever UI patterns, floating labels that break accessibility, or multi-column layouts that make scanning harder.

A person coding a simple contact form in HTML on a computer screen at a desk.

A practical pattern is a single vertical column with labels above fields. UX guidance summarized by UXmatters points to that structure as easier to scan and complete because it reduces cognitive load and keeps the reading flow predictable (UXmatters form design guidance).

Start with semantic HTML

Here's a copy-pasteable base form:

HTML
<form
  action="https://example-form-endpoint.com/submit"
  method="POST"
  accept-charset="UTF-8"
>
  <div>
    <label for="name">Name</label>
    <input
      id="name"
      name="name"
      type="text"
      autocomplete="name"
      required
    />
  </div>

  <div>
    <label for="email">Email</label>
    <input
      id="email"
      name="email"
      type="email"
      autocomplete="email"
      inputmode="email"
      required
    />
  </div>

  <div>
    <label for="message">Message</label>
    <textarea
      id="message"
      name="message"
      rows="6"
      required
    ></textarea>
  </div>

  <button type="submit">Send message</button>
</form>

A few details matter here:

  • label + for pairing: Screen readers need the association. So do users clicking labels on mobile.
  • Useful name attributes: The backend receives keys from name, not id.
  • Correct input types: type="email" gives you browser validation and better mobile keyboards.
  • Native submit behavior: Start with plain HTML before adding JavaScript.

If you want a walkthrough that stays close to this baseline, this free HTML form guide shows the same hosted-endpoint pattern in a stripped-down setup.

Keep fields minimal and defensible

The usual mistake is adding fields because they might be useful later. Phone number is the classic example. Unless your team uses phone follow-up, it often adds friction and privacy hesitation without improving the conversation.

A tighter default is:

  • Name: So the reply feels human.
  • Email: Required if you need to answer.
  • Message: The actual reason they came.

You can add company, budget, or file upload later if the business case is clear. Don't add them “just in case.”

If you can't explain why a field exists, remove it.

Add the small production details early

Even basic HTML can include some quality-of-life improvements:

HTML
<form
  action="https://example-form-endpoint.com/submit"
  method="POST"
  accept-charset="UTF-8"
  novalidate
>
  <input type="hidden" name="subject" value="New website contact submission" />

  <div>
    <label for="name">Name</label>
    <input id="name" name="name" type="text" autocomplete="name" required />
  </div>

  <div>
    <label for="email">Email</label>
    <input id="email" name="email" type="email" autocomplete="email" required />
  </div>

  <div>
    <label for="message">Message</label>
    <textarea id="message" name="message" rows="6" required></textarea>
  </div>

  <p>
    By submitting this form, you agree that we may use your details to reply to your message.
  </p>

  <button type="submit">Send message</button>
</form>

Use novalidate only if you plan to replace native browser messaging with your own accessible validation flow. Otherwise, leave it off.

Connecting Your Form to a Backend Service

A form without a processing endpoint is just UI. Something has to receive the POST request, validate the payload, block obvious spam, and deliver the message somewhere useful.

For static sites, you've got a few realistic options:

Option When it fits Trade-off
Hosted form backend Static sites, fast setup, no server code Third-party dependency
Serverless function You want full control More code, monitoring, and maintenance
Full custom backend Complex workflows or internal systems Highest overhead

If your site is mostly static and the form is a supporting feature, a hosted backend is often the practical call. Tools in this category include Formspree, Getform, Basin, Web3Forms, and Static Forms. The pattern is the same across them: create a form in the dashboard, get an endpoint or API key, and wire it into the HTML.

Screenshot from https://www.staticforms.dev

The plain HTML wiring

With a hosted backend, the form usually changes in only a few places. Here's a realistic example using a public API endpoint and hidden API key:

HTML
<form
  action="https://api.staticforms.dev/submit"
  method="POST"
  accept-charset="UTF-8"
>
  <input type="hidden" name="apiKey" value="YOUR_API_KEY" />
  <input type="hidden" name="redirectTo" value="https://yourdomain.com/contact/thanks" />

  <div>
    <label for="name">Name</label>
    <input id="name" name="name" type="text" required />
  </div>

  <div>
    <label for="email">Email</label>
    <input id="email" name="email" type="email" required />
  </div>

  <div>
    <label for="message">Message</label>
    <textarea id="message" name="message" rows="6" required></textarea>
  </div>

  <button type="submit">Send message</button>
</form>

That's the whole point of this model. You don't need an app server just to receive a few fields and forward them by email.

What the backend should handle for you

When I review form setups, I look for five things immediately:

  1. Submission acceptance
    The endpoint should accept standard application/x-www-form-urlencoded or multipart/form-data posts so plain forms work without extra JS.

  2. Notification delivery
    Somebody on your team has to receive the message, and that path needs to be stable enough for client sites.

  3. Redirect or confirmation support
    Users should land on a success state you control, not a blank browser tab or raw JSON response.

  4. Spam controls
    Honeypot, CAPTCHA support, or both.

  5. Operational visibility
    If a message goes missing, you need logs or a dashboard inbox, not guesswork.

A contact form is part UI, part delivery pipeline. Most bugs happen in the second part.

Redirects, thank-you pages, and error states

Don't stop at “form submits.” Decide what happens next.

A good post-submit path usually includes:

  • A success page or inline success message: Confirms the message was received.
  • Clear next-step copy: Tell the user how you'll respond.
  • An error state: Explain what to do if submission fails.
  • No false positives: Don't show success before the backend accepts the request.

A thank-you page also gives you a clean place for analytics events, alternate contact info, or support documentation links.

When a backend service is the wrong choice

There are cases where hosted form handling isn't enough:

  • You need complex conditional routing based on internal data.
  • You need custom authentication before submission.
  • You must run proprietary server-side checks before accepting the message.
  • Your organization requires strict internal-only processing.

In those cases, use a serverless function or your own backend. But for a normal simple contact form for a website, especially on static hosting, that extra build surface usually isn't worth it.

Choosing the Right Spam Protection

The minute your form goes live, bots will find it. Some will post junk URLs. Some will try credential dumps. Some will send random text to test whether the endpoint is open. Spam protection isn't a nice-to-have. It's part of shipping the feature.

An infographic comparing five effective methods for adding spam protection to a contact form for websites.

Industry guidance for operating forms at scale recommends using a clear confirmation flow, automated acknowledgment, and optional spam controls such as reCAPTCHA or honeypot fields, because the form experience includes what happens after submit, not just before it (ABCSubmit contact form best practices).

If you want a provider-focused walkthrough of common bot patterns and filtering approaches, this spam email bot guide is a useful reference.

Honeypot versus CAPTCHA

Here's the practical comparison:

Method UX cost Protection level Good default
Honeypot None for humans Basic to moderate Yes
reCAPTCHA v2 Visible friction Strong Sometimes
reCAPTCHA v3 Low visible friction Depends on scoring Good when tuned
Cloudflare Turnstile Lower friction Strong Often a solid middle ground
Time checks Invisible Limited alone Only as a secondary layer

For most sites, I start with honeypot plus server-side validation. If spam gets through, I add Turnstile or reCAPTCHA.

Honeypot implementation

A honeypot is just a field humans shouldn't fill. Bots often will.

HTML
<form action="https://api.example.com/submit" method="POST">
  <div style="position:absolute;left:-9999px;" aria-hidden="true">
    <label for="company">Company</label>
    <input
      id="company"
      type="text"
      name="company"
      tabindex="-1"
      autocomplete="off"
    />
  </div>

  <div>
    <label for="name">Name</label>
    <input id="name" name="name" type="text" required />
  </div>

  <div>
    <label for="email">Email</label>
    <input id="email" name="email" type="email" required />
  </div>

  <div>
    <label for="message">Message</label>
    <textarea id="message" name="message" required></textarea>
  </div>

  <button type="submit">Send</button>
</form>

On the backend side, reject the submission if company contains a value.

This method is cheap, invisible, and worth using even if you later add CAPTCHA. Just don't hide the field with display:none if your anti-spam vendor specifically checks for more natural hidden patterns. Follow the provider's docs.

Turnstile and reCAPTCHA trade-offs

CAPTCHAs catch more bots, but they add friction and can hurt accessibility if implemented badly.

reCAPTCHA v2 gives a visible widget or challenge. It's stronger from a blocking perspective, but it interrupts the flow.

reCAPTCHA v3 runs in the background and returns a score. Better UX, but you need backend scoring logic and thresholds.

Cloudflare Turnstile is a strong option when you want bot protection with less visible hassle. It fits well on modern static sites.

A minimal Turnstile setup looks like this:

HTML
<form action="https://api.example.com/submit" method="POST">
  <div>
    <label for="name">Name</label>
    <input id="name" name="name" type="text" required />
  </div>

  <div>
    <label for="email">Email</label>
    <input id="email" name="email" type="email" required />
  </div>

  <div>
    <label for="message">Message</label>
    <textarea id="message" name="message" required></textarea>
  </div>

  <div
    class="cf-turnstile"
    data-sitekey="YOUR_TURNSTILE_SITE_KEY"
  ></div>

  <button type="submit">Send</button>
</form>

<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>

What actually works in production

Don't rely on one trick. Use layers.

  • First layer: Required fields, type validation, message length checks.
  • Second layer: Honeypot.
  • Third layer: CAPTCHA if the site attracts repeated spam.
  • Fourth layer: Provider-side filtering, rate limiting, and webhook validation where available.

The right anti-spam setup is the lightest one that keeps your inbox usable.

Configuring Notifications and Advanced Workflows

A submitted form should trigger action, not just generate an email. Too often, forms fail in this way: the browser reports success, yet no team member sees the message, the submitter receives no confirmation, and the inquiry remains in a personal inbox until someone notices it.

That gap is common. In lead-management research, only 15.6% of businesses sent an auto-reply after a form submission, even though that one step confirms receipt and lowers uncertainty for the person who just contacted you (Leadferno research on form follow-up).

A diagram illustrating a seven-step business workflow from initial form submission to final analytics and reporting.

Send notifications to the right place

The first configuration decision is simple: who gets the message?

For a solo founder, one inbox may be enough. For a team, route based on purpose. Sales inquiries can go one place, support another, partnership requests somewhere else. If your provider doesn't offer conditional routing, use a webhook into automation tools that do.

A sensible notification setup often includes:

  • Primary email recipient: The person or shared inbox that owns the response.
  • Fallback visibility: A dashboard inbox or archived submission store.
  • Structured subject lines: Easy to scan and filter.
  • Field normalization: Consistent names so automations don't break.

Auto-replies are part of the product

An auto-responder does three jobs:

  1. Confirms the message was received.
  2. Tells the user what happens next.
  3. Reduces duplicate submissions.

Keep it plain. Don't turn it into marketing copy.

Plain Text
Subject: We got your message

Hi {{name}},

Thanks for reaching out. We received your message and will reply by email.

If your request is urgent, you can also contact us at support@yourdomain.com.

Message received:
{{message}}

Short beats clever here.

A confirmation email closes the loop your form opened.

Webhooks turn a form into a workflow

Once the submission leaves the browser, email is only one possible destination. A webhook lets you POST the payload to another service, which is where forms become operational tools instead of inbox feeders.

Common webhook destinations:

  • Slack: Notify a team channel immediately.
  • Google Sheets: Keep a lightweight record for lead triage.
  • Zapier, Make, or n8n: Fan submissions out into CRMs, task tools, or ticketing systems.
  • Custom endpoint: Run your own logic, tagging, or enrichment.

A webhook payload usually looks like this:

JSON
{
  "name": "Ada Lovelace",
  "email": "ada@example.com",
  "message": "I'd like to discuss a project.",
  "submittedAt": "2026-06-04T10:00:00Z"
}

If you control the receiving endpoint, validate signatures or shared secrets when the provider supports them. Don't accept unauthenticated POSTs blindly just because they look internal.

File uploads and practical constraints

File upload changes the form's complexity immediately. It affects payload size, spam risk, storage, and your email workflow.

If your provider supports uploads, keep the rules obvious:

  • Accept only needed types: PDFs, docs, images, not everything.
  • Keep the limit clear: Some hosted providers support uploads up to 5MB per submission.
  • Use multipart/form-data: Required for real file transport.
  • Don't rely on email attachments alone: Prefer links or managed storage when available.

Example:

HTML
<form
  action="https://api.example.com/submit"
  method="POST"
  enctype="multipart/form-data"
>
  <div>
    <label for="attachment">Attachment</label>
    <input
      id="attachment"
      name="attachment"
      type="file"
      accept=".pdf,.doc,.docx,.png,.jpg,.jpeg"
    />
  </div>
</form>

Deliverability and custom-domain email

If the form backend sends notifications or auto-replies from your brand domain, email authentication matters. SPF, DKIM, and DMARC help receiving mail systems trust that those messages are legitimate.

The practical rule is simple: if you want branded notification emails and auto-responders to land reliably, configure your sending domain correctly and test it with real inboxes. Without that, even a working form can look flaky because delivery quality is inconsistent.

Privacy matters here too. If you store submissions, know where that data lives, who can access it, and how long you keep it. Contact forms collect personal data quickly, even when the UI looks simple.

Integrating Your Form with Modern Frameworks

Plain HTML should always be your starting point. But in React, Next.js, or Vue, you may want async submission, inline validation, and a better pending or success state without a full-page reload.

That's fine. Just don't rebuild the browser's form behavior badly.

Independent UX guidance also recommends asking for only the minimum fields you really need, and specifically avoiding phone-number collection unless there's a strong reason, because many users hesitate to share it due to spam-call concerns (privacy-minimal form design guidance).

React example with fetch

This pattern keeps the form controlled enough to show state, but not so abstract that it's hard to debug.

JSX
import { useState } from "react";

export default function ContactForm() {
  const [form, setForm] = useState({
    name: "",
    email: "",
    message: "",
  });
  const [status, setStatus] = useState("idle");

  async function handleSubmit(e) {
    e.preventDefault();
    setStatus("submitting");

    const response = await fetch("https://api.staticforms.dev/submit", {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        apiKey: "YOUR_API_KEY",
        name: form.name,
        email: form.email,
        message: form.message,
      }),
    });

    const data = await response.json();

    if (data.success) {
      setStatus("success");
      setForm({ name: "", email: "", message: "" });
    } else {
      setStatus("error");
    }
  }

  function handleChange(e) {
    setForm((prev) => ({
      ...prev,
      [e.target.name]: e.target.value,
    }));
  }

  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="name">Name</label>
      <input id="name" name="name" value={form.name} onChange={handleChange} required />

      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" value={form.email} onChange={handleChange} required />

      <label htmlFor="message">Message</label>
      <textarea id="message" name="message" value={form.message} onChange={handleChange} required />

      <button type="submit" disabled={status === "submitting"}>
        {status === "submitting" ? "Sending..." : "Send message"}
      </button>

      {status === "success" && <p>Your message was sent.</p>}
      {status === "error" && <p>Something went wrong. Please try again.</p>}
    </form>
  );
}

Next.js client component example

In Next.js App Router, keep this in a client component.

JSX
"use client";

import { useState } from "react";

export default function ContactForm() {
  const [pending, setPending] = useState(false);
  const [result, setResult] = useState("");

  async function onSubmit(e) {
    e.preventDefault();
    setPending(true);
    setResult("");

    const formData = new FormData(e.currentTarget);
    formData.append("apiKey", "YOUR_API_KEY");

    const response = await fetch("https://api.staticforms.dev/submit", {
      method: "POST",
      body: formData,
    });

    const data = await response.json();

    if (data.success) {
      setResult("success");
      e.currentTarget.reset();
    } else {
      setResult("error");
    }

    setPending(false);
  }

  return (
    <form onSubmit={onSubmit}>
      <div>
        <label htmlFor="name">Name</label>
        <input id="name" name="name" type="text" required />
      </div>

      <div>
        <label htmlFor="email">Email</label>
        <input id="email" name="email" type="email" required />
      </div>

      <div>
        <label htmlFor="message">Message</label>
        <textarea id="message" name="message" rows="6" required />
      </div>

      <button type="submit" disabled={pending}>
        {pending ? "Sending..." : "Send message"}
      </button>

      {result === "success" && <p>Thanks. We received your message.</p>}
      {result === "error" && <p>Submission failed. Please try again.</p>}
    </form>
  );
}

If you're working in that stack, this Next.js form backend walkthrough is the relevant implementation reference.

Vue example

Vue stays close to the same shape:

Vue
<script setup>
import { reactive, ref } from "vue";

const form = reactive({
  name: "",
  email: "",
  message: ""
});

const status = ref("idle");

async function submitForm() {
  status.value = "submitting";

  const response = await fetch("https://api.example.com/submit", {
    method: "POST",
    headers: {
      "Content-Type": "application/json"
    },
    body: JSON.stringify(form)
  });

  status.value = response.ok ? "success" : "error";
}
</script>

<template>
  <form @submit.prevent="submitForm">
    <label for="name">Name</label>
    <input id="name" v-model="form.name" name="name" required />

    <label for="email">Email</label>
    <input id="email" v-model="form.email" name="email" type="email" required />

    <label for="message">Message</label>
    <textarea id="message" v-model="form.message" name="message" required />

    <button type="submit" :disabled="status === 'submitting'">
      {{ status === "submitting" ? "Sending..." : "Send message" }}
    </button>

    <p v-if="status === 'success'">Message sent.</p>
    <p v-if="status === 'error'">Something went wrong.</p>
  </form>
</template>

Webflow and WordPress notes

In Webflow, you can replace the default action with a custom endpoint using embedded code or custom form handling patterns, depending on how much control you need.

In WordPress, a plain Custom HTML block works fine when you don't want a heavy plugin stack. Just make sure the theme doesn't strip attributes you need and that caching or optimization plugins aren't interfering with scripts used for CAPTCHA.


If you want a no-backend way to ship a production-ready contact form on a static site, Static Forms is one option to consider. It accepts plain HTML form posts at https://api.staticforms.dev/submit, works with frameworks and site builders, supports spam controls like honeypot, reCAPTCHA, and Turnstile, and can route submissions to email or other tools without maintaining your own form handler.