Static Site Contact Form: One HTML Form for Any Host

Static Site Contact Form: One HTML Form for Any Host

10 min read
Static Forms Team
static sitescontact formtutorial
Share:

The simplest way to add a contact form to a static site is to point a plain HTML form at a form backend. Set the form's action to https://api.staticforms.dev/submit, add your API key in a hidden apiKey field, and publish. Each submission arrives by email. You don't need a server, a build step or JavaScript.

The same snippet works on GitHub Pages, Netlify, Vercel and Cloudflare Pages, because the host only serves the file. Two things differ by host, and both are easy to get wrong: the address you put in redirectTo, and where your Content Security Policy lives if you have one. Below you'll find the form, a table of those per-host details, and a three-step test that proves a message reaches your inbox.

We make Static Forms (staticforms.dev), so the examples use it. New to the idea? Read what a static form is first.

Using a framework? The Static Forms examples repo has a ready-to-deploy contact form for 13 frameworks, including Next.js, Nuxt, React, Vue, SvelteKit, Astro, Angular, React Router, SolidStart, Qwik, Gatsby, Eleventy and plain HTML. Each one has a one-click Deploy to Vercel button.

Why a static site can't handle a form on its own

A static host serves files. When a visitor presses Send, the browser makes a POST request to whatever URL is in the form's action, and a file server has nothing listening for it.

Something has to receive that request, check it for spam, and turn it into an email. You can write that yourself as a serverless function with an email provider, or you can post to a form endpoint that already does it. For a contact form, the endpoint is usually less to maintain: there's no function to deploy and no email credentials in your project.

A static page on any host posts the form to api.staticforms.dev/submit, which checks for spam and sends an email or webhook

Prerequisites

  • A static site on any host, or a single HTML file you can publish.
  • A free Static Forms account. The Free plan covers one form and 250 submissions every 30 days.
  • Your form's API key, from the General tab of the form in the dashboard.

Step 1: Paste the contact form

Put this form in your contact page and replace YOUR_API_KEY with your key:

HTML
<form action="https://api.staticforms.dev/submit" method="POST">
  <input type="hidden" name="apiKey" value="YOUR_API_KEY">
  <input type="hidden" name="redirectTo" value="https://example.com/thank-you.html">
  <input type="hidden" name="subject" value="New message from example.com">

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

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

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

  <!-- Honeypot: hidden from people, filled in by bots -->
  <input type="text" name="honeypot" style="display:none" tabindex="-1" autocomplete="off">

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

Every field with a name shows up in the notification email, so add phone or company the same way. These fields have special meanings:

Field What it does
apiKey Required. Tells Static Forms which form the submission belongs to.
email Becomes the reply-to address, so you can answer by replying to the notification.
replyTo Optional. A reply-to address that wins over email.
subject Optional. The subject line of the notification email.
redirectTo Optional. A full http:// or https:// URL to send the visitor to after a successful submission.
honeypot Any field whose name contains "honeypot". Keep it hidden; a submission that fills it in is treated as spam.

The API key is visible in your page source, and that's expected. It identifies the form; it can't read your submissions or sign in to your account. The API reference lists every parameter.

Step 2: Add a thank-you page

With redirectTo, a successful submission answers with a 303 redirect to that URL. Two details catch people out:

  • It must be an absolute URL. A relative path such as /thanks is ignored, and the visitor sees a plain "Form submitted successfully" page instead.
  • The page has to exist. Create thank-you.html (or whatever you named it) and deploy it with the rest of the site.

If you'd rather keep the visitor on the same page, submit with fetch and leave redirectTo out. The HTML guide has that version.

Per-host notes: GitHub Pages, Netlify, Vercel and Cloudflare Pages

The form itself doesn't change between hosts. These are the parts that do:

Host redirectTo address Where a CSP lives Watch out for Guide
GitHub Pages https://USERNAME.github.io/REPO/thank-you.html for a project site; https://USERNAME.github.io/thank-you.html for a user or organization site; or your custom domain A <meta http-equiv="Content-Security-Policy"> tag in your HTML, if you added one Project sites live under /REPO/, so a URL without it points at a page that doesn't exist GitHub Pages contact form
Netlify Your *.netlify.app address or custom domain A _headers file in the publish directory, or [[headers]] in netlify.toml Leave out the netlify and data-netlify attributes. They opt the form into Netlify Forms, which processes it at build time Netlify guide
Vercel Your *.vercel.app address or custom domain headers in vercel.json, or your framework's header config If you read the key from a NEXT_PUBLIC_ variable, it's inlined at build time, so redeploy after changing it Vercel contact form
Cloudflare Pages Your *.pages.dev address or custom domain A _headers file in the build output directory If your framework copies files from public/ or static/, put _headers there so it reaches the output Cloudflare Pages contact form

The header-file locations and the Netlify attribute behaviour come from Netlify, Vercel and Cloudflare documentation, checked 28 October 2026.

If your site sends a Content Security Policy

If your site sends no CSP, or its CSP has no form-action directive, there's nothing to do. form-action has no default-src fallback, so without it the browser lets the form post anywhere.

If your policy does include form-action, add the Static Forms origin to it:

HTTP
Content-Security-Policy: default-src 'self'; form-action 'self' https://api.staticforms.dev

Keep 'self' there. MDN notes browsers have disagreed on whether form-action also applies to the redirect after a submission: Chrome blocked a disallowed redirect where Firefox didn't. With 'self' in the list, a redirectTo page on your own domain is allowed in every browser.

Merge the directive into the policy you already ship rather than replacing it with this short example. A blocked form shows a form-action error in the console and no request in the Network panel. The Cloudflare Pages contact form guide shows a form-action policy in a _headers file.

Step 3: Test it in three steps

Run this on the deployed site, not only on localhost, because the host's headers and URLs are part of what you're testing:

  1. Submit. Open the live contact page, fill in every field with an email address you can read, and press Send.
  2. Check where you land. You should reach your redirectTo page. A plain "Form submitted successfully" page means the submission worked but redirectTo was missing or relative. An error page tells you the reason: the troubleshooting table maps each one to a fix.
  3. Check the inbox, then the Inbox. The notification should reach your email. If it doesn't, open the form's Inbox in the dashboard. A submission listed there means the form works and the problem is delivery, so check your spam folder and email delivery. Nothing listed means the request never arrived (unless the spam filter or honeypot caught it), so go back to the browser console.

Then reply to the notification once. The reply should go to the address you typed in the form, which confirms the email field is named correctly.

Keep spam out

Every plan includes the built-in spam filter and the honeypot field from Step 1. For more, add a CAPTCHA: turn it on in your form's security settings, then add the widget.

reCAPTCHA v2 works on the Free plan:

HTML
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Contact</title>
  <script src="https://www.google.com/recaptcha/api.js" async defer></script>
</head>
<body>
  <form action="https://api.staticforms.dev/submit" method="POST">
    <input type="hidden" name="apiKey" value="YOUR_API_KEY">
    <label for="email">Email</label>
    <input type="email" id="email" name="email" required>
    <label for="message">Message</label>
    <textarea id="message" name="message" required></textarea>
    <div class="g-recaptcha" data-sitekey="YOUR_RECAPTCHA_SITE_KEY"></div>
    <button type="submit">Send message</button>
  </form>
</body>
</html>

Cloudflare Turnstile, hCaptcha and ALTCHA are (Starter and up). Once a CAPTCHA is on, a submission without a valid token is rejected with a 403, so add the widget before you switch it on. Our reCAPTCHA guide explains the keys, and you can also limit which sites may submit with domain restriction (Starter and up).

File attachments

To accept files, add enctype="multipart/form-data" to the form and a file input. File uploads are (Starter and up); on the Free plan a submission with a file is rejected with a 400.

HTML
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Send a file</title>
</head>
<body>
  <form action="https://api.staticforms.dev/submit" method="POST" enctype="multipart/form-data">
    <input type="hidden" name="apiKey" value="YOUR_API_KEY">
    <label for="email">Email</label>
    <input type="email" id="email" name="email" required>
    <label for="attachment">File</label>
    <input type="file" id="attachment" name="attachment" accept=".pdf,.doc,.docx">
    <button type="submit">Send</button>
  </form>
</body>
</html>

The file uploads docs cover the size limit.

Static site generators and frameworks

The form is plain HTML, so Hugo, Jekyll, Eleventy and Gatsby templates can include it as is. For framework-specific versions:

Complete Working Example

A full contact page with styling, the honeypot and a thank-you redirect. Save it as contact.html, replace the key and the redirectTo address with your host's, and deploy it next to a thank-you.html:

HTML
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Contact Us</title>
  <style>
    body {
      font-family: system-ui, -apple-system, sans-serif;
      margin: 0;
      padding: 2rem 1rem;
    }
    .contact-form {
      max-width: 600px;
      margin: 0 auto;
      padding: 2rem;
      border-radius: 8px;
      box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
    }
    .form-group {
      margin-bottom: 1.5rem;
    }
    label {
      display: block;
      margin-bottom: 0.25rem;
      font-weight: 600;
    }
    .form-control {
      display: block;
      width: 100%;
      box-sizing: border-box;
      padding: 0.75rem;
      border: 1px solid #ccc;
      border-radius: 4px;
      font-size: 1rem;
    }
    .submit-btn {
      padding: 0.75rem 1.5rem;
      background-color: #2563eb;
      color: #fff;
      border: none;
      border-radius: 4px;
      cursor: pointer;
      font-size: 1rem;
    }
    .submit-btn:hover {
      background-color: #1d4ed8;
    }
  </style>
</head>
<body>
  <div class="contact-form">
    <h1>Contact us</h1>
    <form action="https://api.staticforms.dev/submit" method="POST">
      <input type="hidden" name="apiKey" value="YOUR_API_KEY">
      <input type="hidden" name="redirectTo" value="https://example.com/thank-you.html">
      <input type="hidden" name="subject" value="New message from example.com">

      <div class="form-group">
        <label for="name">Name</label>
        <input type="text" id="name" name="name" class="form-control" autocomplete="name" required>
      </div>

      <div class="form-group">
        <label for="email">Email</label>
        <input type="email" id="email" name="email" class="form-control" autocomplete="email" required>
      </div>

      <div class="form-group">
        <label for="message">Message</label>
        <textarea id="message" name="message" class="form-control" rows="5" required></textarea>
      </div>

      <input type="text" name="honeypot" style="display:none" tabindex="-1" autocomplete="off">

      <button type="submit" class="submit-btn">Send message</button>
    </form>
  </div>
</body>
</html>

And a minimal thank-you.html to go with it:

HTML
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Thanks</title>
</head>
<body>
  <h1>Thanks, your message is on its way.</h1>
  <p><a href="/">Back to the home page</a></p>
</body>
</html>

Troubleshooting

What you see Why, and what to do
A plain "Form submitted successfully" page The submission worked. Add redirectTo with a full URL to show your own page.
A 404 after submitting redirectTo points at a page that isn't deployed, or at a GitHub Pages project URL without /REPO/.
"Invalid API key" The apiKey value doesn't match the key on the form's General tab. Check for extra spaces.
A 403 that mentions CAPTCHA, Turnstile, hCaptcha or Altcha A CAPTCHA is on for the form, but the submission had no valid token. Add the widget or turn the CAPTCHA off.
The console reports a form-action violation Your CSP blocks the endpoint. Add https://api.staticforms.dev to form-action.
No email arrives Check your spam folder, then the Inbox in the dashboard. If the submission is listed there, the problem is delivery.

More fixes are in Common Issues.

Start with the snippet

Copy the form from Step 1, set redirectTo from the per-host table, and run the three-step test on your live URL. The quick start walks through creating the form and finding your key, and getting started with Static Forms covers the rest of the dashboard.