Tutorials

The complete guide to contact forms for developers

May 20, 202612 min read
The complete guide to contact forms for developers

You need a contact form. Seems like a small task: a couple of inputs, a submit button, an email lands in your inbox. You could sketch it in an afternoon.

Then the questions start. Do you need JavaScript or will a plain HTML form do? Where does the submitted data go: email only, or a database? What happens when the email doesn't arrive? What about the bots that hit every public form endpoint within hours of it going live?

None of these are hard questions on their own. But they all have to be answered, and the answers interact with each other. This guide works through all of them: what a production-ready contact form actually needs, which approach fits your stack, and where to go deeper on each piece.

You can also jump straight to a full set of working templates if you'd rather start from code.

What a production-ready contact form actually needs

A working prototype and a production-ready form are different things. A prototype submits data. A production form submits data reliably, handles failures gracefully, and doesn't fill your inbox with bot submissions.

Here's what that actually requires:

  • Submission storage: Email delivery fails. Servers go down, addresses change, messages get filtered. If your form sends email and nothing else, a delivery failure means a lost submission with no recovery path. A proper setup writes every submission to a database first, then sends the notification email on top of that.

  • Spam protection: Every publicly accessible form endpoint gets discovered by bots. Often within hours of going live. Without protection, a spam flood makes your notification inbox useless and your submission log noisy. The form needs at minimum a honeypot field, and ideally rate limiting on top of that.

  • Server-side validation: Client-side validation is good UX. It catches honest mistakes before the user ever submits. But it is not a security boundary. Anyone can submit your form directly with curl and bypass your JavaScript entirely. Every constraint you enforce in the browser has to be re-enforced on the server.

  • Reliable email notifications: Knowing when a submission arrives is the point of the form. But email delivery involves SMTP configuration, DNS records, deliverability reputation, and filtering at the receiving end. Getting this right usually means using a transactional email service rather than sending from your own mail server.

  • UX states: A form with no feedback on submission is a bad experience. Users don't know if it worked. They submit again, you get duplicates. The form needs to handle loading, success, and error states explicitly, which means JavaScript for most modern setups.

For a complete map of everything that goes into form handling at the infrastructure level, The hidden complexity of handling form submissions covers it in full.

HTML forms vs JavaScript forms: which do you need?

Most tutorials default to React. That's not always the right call.

Plain HTML forms work without JavaScript, submit natively via the browser, and redirect to a thank-you page on success. They're simpler to build and more accessible by default. The limitation is control: no loading state, no inline success message, no error display without a page reload.

JavaScript fetch-based forms give you full control over the submission experience. You decide what happens while the form is submitting, when it succeeds, and when it fails. The tradeoff is that you write more code and need a JavaScript-capable environment.

HTML formJavaScript fetch
JavaScript requiredNoYes
Loading stateNo (browser default)Yes
Inline success messageNo (redirect only)Yes
Error display without reloadNoYes
File uploadsYesYes (with FormData)
Works on static sitesYesYes
Good forSimple pages, progressive enhancementReact, Next.js, Astro apps with interactive UI

If your page is mostly static and you don't need a custom success experience, HTML is enough. If you're building in a JavaScript framework and want control over the submission UX, use the fetch approach.

Which backend approach should you use?

Three main options, with different tradeoffs on control versus time to ship.

  • Build your own endpoint: a serverless function or Route Handler that receives submissions, validates them, stores them to a database, and sends email. Full control, but also full responsibility: CORS headers, spam filtering, rate limiting, email delivery, and storage are all yours to build and maintain. For most contact forms on most projects, this is building infrastructure around a small feature. The hidden complexity of handling form submissions maps out what that infrastructure actually looks like.

  • Serverless function plus email API: you write the endpoint, but delegate email delivery to a transactional service like Resend or SendGrid. This removes the email infrastructure problem. You're still responsible for spam protection, input validation, and storing submissions somewhere.

  • Form backend service: you point your form at a hosted endpoint that already handles storage, spam filtering, notifications, and rate limiting. The endpoint URL is available immediately after creating a form in the dashboard. See the Formtorch features page for what's included, and the pricing page for what's available at each tier. For most projects: marketing sites, portfolios, SaaS landing pages, client work, this is the right default. You can always move to a custom solution if your requirements outgrow it.

The decision comes down to what you're actually building. If forms are a core product feature with specific workflow requirements, owning the backend makes sense. If you need a contact form to work so you can focus on building the actual product, use a service.

Framework implementations

The submission pattern is the same regardless of framework: a form sends data to an endpoint, and either the browser handles the redirect or JavaScript handles the response. What differs is how you structure the component and manage state.

Plain HTML

The smallest working form you can ship:

<form action="https://formtorch.com/f/YOUR_FORM_ID" method="POST">
  <input name="name" type="text" placeholder="Your name" required />
  <input name="email" type="email" placeholder="Your email" required />
  <textarea name="message" placeholder="Message" required></textarea>
  <button type="submit">Send message</button>
</form>

No JavaScript, no dependencies. The browser handles the submission, Formtorch stores it and sends a notification, then redirects to a default thank-you page. For the full walkthrough including custom redirect pages and hidden metadata fields, see HTML contact form that sends email.

Static sites

Static sites (Jekyll, Hugo, Eleventy, plain HTML) have no server to run backend logic. The form endpoint has to live somewhere else. A hosted form backend is the natural fit: drop the action URL into your form and submissions route through without touching your build process.

The guide to adding a contact form to a static website covers this setup in detail, including how to use the _redirect hidden field to send users to your own thank-you page.

React

In React, you manage form state explicitly. A minimal pattern:

// components/ContactForm.tsx
"use client";

import { useState } from "react";

type State = "idle" | "loading" | "success" | "error";

export function ContactForm() {
  const [state, setState] = useState<State>("idle");

  async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    setState("loading");
    try {
      const res = await fetch("https://formtorch.com/f/YOUR_FORM_ID", {
        method: "POST",
        headers: { "X-Requested-With": "XMLHttpRequest" },
        body: new FormData(e.currentTarget),
      });
      if (!res.ok) throw new Error();
      setState("success");
    } catch {
      setState("error");
    }
  }

  if (state === "success") return <p>Message sent. Talk soon.</p>;

  return (
    <form onSubmit={handleSubmit}>
      <input name="name" type="text" required />
      <input name="email" type="email" required />
      <textarea name="message" required />
      {state === "error" && <p>Something went wrong. Try again.</p>}
      <button type="submit" disabled={state === "loading"}>
        {state === "loading" ? "Sending…" : "Send message"}
      </button>
    </form>
  );
}

The X-Requested-With header tells the endpoint to return JSON instead of an HTML redirect. The full tutorial, including client-side validation and a react-hook-form integration, is at How to build a contact form in React.

Next.js

Next.js gives you two options: a plain fetch (same pattern as above) or a Server Action. For most contact forms, the fetch approach is simpler and works identically on the App Router or Pages Router. Server Actions are worth considering when you need tight integration with server-side logic in your own codebase.

The Next.js contact form guide covers both approaches and explains how to handle the App Router's "use client" boundary.

Astro

Astro renders pages as static HTML by default. The HTML form approach works directly: set the action attribute and let the browser handle submission. If you want JavaScript-powered UX states, drop the form into an Astro island with a client:load or client:visible directive.

The contact form in Astro guide shows both approaches and covers the island hydration tradeoffs.

Spam protection

Every public form endpoint gets hit by bots. The question is not whether you'll receive spam submissions, but how many and how fast.

Three layers work together and defend against different threat types:

  • Honeypot fields: a hidden input that real users never see or fill. Bots that auto-fill all form fields include it in the submission, which flags the submission immediately. This is the lowest-friction spam filter available: no CAPTCHA, no user friction, catches a large percentage of unsophisticated bots.
<!-- Hide with CSS, not type="hidden": bots that ignore CSS still fill it -->
<input
  name="_honeypot"
  tabindex="-1"
  autocomplete="off"
  style="display:none"
  aria-hidden="true"
/>
  • Rate limiting: caps submissions per IP address per time window. Content-based filters look at what was submitted; rate limiting looks at volume from a given source. A bot can submit realistic-looking content that passes content filters. Rate limiting catches it based on frequency alone.

  • Content scoring: analyzing submission content for spam signals, keyword patterns, and behavioral anomalies. More sophisticated than a honeypot but also more work to build and tune over time as spam patterns evolve.

For a detailed breakdown of how each technique works and when to use it, see How to prevent spam in contact forms. For the endpoint security side, including CORS configuration and rate limiting implementation, see Best ways to secure a form endpoint.

Validation: client-side and server-side

Client-side validation and server-side validation solve different problems. You need both, but for different reasons.

Client-side validation is UX. It catches honest input mistakes before the user submits, provides immediate feedback, and prevents unnecessary round-trips. The browser's built-in required, type="email", and minlength attributes handle most basic cases without any JavaScript.

Server-side validation is a security boundary. Anyone can send a POST request to your endpoint without touching your HTML form: curl, browser developer tools, automated scripts. All of them bypass client-side validation. Every constraint you enforce in the browser must be enforced again on the server.

The minimum server-side check for a contact form:

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;
}

The most common validation mistakes in JavaScript-based forms, and how to fix them, are covered in AJAX form validation: common mistakes and how to fix them. For validation patterns that scale across larger forms, see How to design a scalable form validation system.

Email notifications and submission storage

Email is a delivery mechanism, not a storage mechanism. Treating it as both creates a fragile system.

When a form sends email only, any failure in the delivery chain is a lost submission. The email could be filtered to spam. The sending server could be temporarily down. The destination inbox could be full. None of those failures give the submitter any indication that something went wrong, and you have no record of what was sent.

The correct model: write the submission to storage first, then send the notification. If the notification fails, the submission still exists in the database. You can retrieve it when you check the dashboard, when your error monitoring surfaces the failure, or when a delivery retry eventually succeeds.

For a complete guide to setting up form submission notifications, including the tradeoffs between SMTP and transactional email APIs, see A developer's guide to email notifications for form submissions.

Before you ship: a production readiness checklist

  • Server-side validation on all required fields
  • Spam protection in place (at minimum: honeypot field)
  • Endpoint is served over HTTPS
  • Form has loading, success, and error states
  • Submissions stored before notification is sent
  • Notification email address confirmed and delivery tested end-to-end
  • Form tested on mobile and keyboard navigation verified

That last point is where a lot of forms quietly fail after launch. The 8 common mistakes developers make with contact forms covers the ones that show up most often in production.

Frequently asked questions

Do I need a backend to add a contact form to my website?

Not in the traditional sense. You need something to receive and process the submission, but that doesn't have to be server code you write and maintain. A hosted form backend service receives submissions via a URL you point your form at. Your site stays fully static, and you don't run any server. The form works the same way regardless of how your site is built.

What's the simplest way to add a contact form to a static site?

A plain HTML form with the action attribute pointing at a form endpoint. No JavaScript required. The browser handles the submission natively, and the service handles storage and notifications on the other end. See how to add a contact form to a static website for the full setup including custom redirect pages.

How do I stop my contact form from getting spam?

Start with a honeypot field: a hidden input that bots fill but real users never touch. That alone stops most unsophisticated bot traffic. Add rate limiting to catch volume-based attacks. If you're using a form backend service, spam filtering is typically included and running by default. For building your own defense, How to prevent spam in contact forms covers the full layered approach.

What happens if someone submits the form while my email is down?

If your form sends email only, the submission is lost with no recovery path. The fix is to store submissions before sending any notification and treat email as a layer on top of storage. If delivery fails, the submission still exists and you can retrieve it when the problem is resolved. Most form backend services handle this automatically.

Ready to add a contact form?

Create a free Formtorch account and get your endpoint URL in under two minutes. No backend code to write.

Related posts
How to build a contact form in Astro
How to build a contact form in Astro
TutorialsMarch 29, 20268 min read