RG Forms logoRG Forms
Plain HTML

HTML contact form with no backend

If your website is genuinely just files — an index.html, a stylesheet, maybe a logo — you don’t want a framework, a package manager, or a deploy pipeline just to collect an email address.

You don’t need one. Here’s the whole thing.

Last updated August 19, 2026

The short answer

You can run a fully working contact form from a plain HTML file with no backend by POSTing the form data to a hosted endpoint with JavaScript's fetch(). With RG Forms the endpoint is a Google Apps Script web app created inside your own Google account, so the submission goes from the visitor's browser straight to your own Google Sheet. No PHP, no Node, no build step, and no server to keep alive — the entire integration is one form element and about fifteen lines of JavaScript.

The complete form

Paste this into your page, replace the endpoint URL with your own, and you’re done. No dependencies, no bundler, nothing to install.

index.html
<form id="contact-form">
  <label>Name <input name="name" required /></label>
  <label>Email <input type="email" name="email" required /></label>
  <label>Message <textarea name="message" required></textarea></label>

  <!-- Honeypot: humans never see it, bots fill it in -->
  <input type="text" name="_hp" tabindex="-1" autocomplete="off"
         style="position:absolute;left:-9999px" aria-hidden="true" />

  <button type="submit">Send</button>
  <p id="form-status" role="status"></p>
</form>

<script>
  const ENDPOINT = "https://script.google.com/macros/s/AKfycb.../exec";
  const form = document.getElementById("contact-form");
  const status = document.getElementById("form-status");

  form.addEventListener("submit", async (event) => {
    event.preventDefault();
    status.textContent = "Sending…";

    const res = await fetch(ENDPOINT, {
      method: "POST",
      // text/plain avoids the CORS preflight Apps Script can't answer
      headers: { "Content-Type": "text/plain" },
      body: JSON.stringify({
        tab: "contact",
        fields: Object.fromEntries(new FormData(form)),
      }),
    });

    const data = await res.json();
    status.textContent =
      data.result === "success" ? "Thanks — we'll be in touch." : "Something went wrong.";
    if (data.result === "success") form.reset();
  });
</script>

Everything above is standard browser API — FormData, fetch, JSON.stringify. It works in every browser released in the last several years.

What each piece is doing

`event.preventDefault()` stops the browser’s default form submission, which would navigate away to a URL your static host can’t handle.

`Object.fromEntries(new FormData(form))` turns the form into a plain object using the name attribute of each input. Name your inputs to match your field keys and there is no mapping code to write or keep in sync.

`Content-Type: text/plain` is the one non-obvious line. Sending application/json makes the browser fire a CORS preflight OPTIONS request first, and Apps Script web apps can’t respond to OPTIONS — so the request never happens. text/plain is a “simple” content type, which skips the preflight. The body is still a JSON string and the script parses it exactly the same.

The `_hp` input is a honeypot. It sits off-screen where no human will ever type into it, so anything that arrives with it filled in came from a bot. Those submissions are dropped without saving — and the response still says success, so the bot has no idea it was caught.

Where the data goes

Each submission becomes a row in a Google Sheet in your own Drive, with a submitted_at timestamp and one column per field. That’s a genuinely good place for contact submissions to live: you can sort and filter them, add a “replied?” column, share the sheet with a colleague, chart them, or pull them into anything that reads Sheets.

If you switch on email notifications, each submission also arrives in your inbox — sent from your own Google account, with the visitor’s email set as the reply-to address, so you can just hit Reply.

Making it production-ready

The snippet above works. These four additions make it hold up in the real world.

  • Disable the button while sendingotherwise an impatient double-click writes two rows.

  • Handle the failure pathwrap the fetch in try/catch and show a message with a fallback email address if it throws. Networks fail.

  • Label your inputs properlya real <label> per field, not just a placeholder. It’s better for screen readers, and it measurably improves completion rates.

  • Add Turnstile if you get spamthe honeypot handles low-effort bots. For anything more determined, RG Forms can verify a Cloudflare Turnstile token server-side in your script before saving the row.

Common questions

Does this need any JavaScript build tooling?

None. It’s an inline <script> tag in an HTML file. No npm, no bundler, no transpiler — open the file in a browser and it works.

What if the visitor has JavaScript disabled?

The form won’t submit, because the whole approach depends on fetch. In practice this affects a vanishingly small share of visitors, but if it matters to you, include a visible email address as a <noscript> fallback.

Can I add file uploads?

Not through this endpoint — it handles text fields only. If you need attachments, ask people to email you directly or link to a Google Drive upload form.

How do I add a second form to the same site?

Add a form in your RG Forms dashboard — it becomes a new tab in the same Sheet. Use the same endpoint URL and change tab: "contact" to the new tab name.

More answers in the full FAQ.

Your endpoint goes live in ~90 seconds