โ† Back to Blog
Tutorial

How to Send HTML Form Data to Google Sheets (3 Methods, No Backend)

๐Ÿ“… February 24, 2026 โฑ๏ธ 12 min read ๐Ÿท๏ธ Google Sheets ยท Webhooks ยท Forms

โšก TL;DR โ€” Skip to what you need

You have an HTML form. You want submissions to land in Google Sheets automatically โ€” without writing a server, managing a database, or paying for enterprise software.

This is one of the most searched questions in frontend development, and there are three real ways to do it. I'll show you all three โ€” with full code โ€” so you can pick the one that fits your project.

โš ๏ธ
The approach that doesn't work: <form action="mailto:"> and <form action="https://sheets.google.com/..."> both fail. Google Sheets doesn't accept direct POST requests from HTML forms. You need a bridge โ€” one of the 3 methods below.
Method 1: Google Apps Script (Free, DIY)
Free Requires Google account Manual setup No 3rd party

Google Apps Script lets you write JavaScript that runs on Google's servers and can read/write Google Sheets. You deploy it as a "web app" that accepts POST requests โ€” essentially a free serverless function that pipes data into your spreadsheet.

This is the most self-contained approach, but also the most brittle: Google frequently breaks Apps Script deployments on quota limits, authentication changes, and CORS policy updates.

Step 1: Create your Google Sheet

Create a new Google Sheet at sheets.google.com. Add column headers in row 1 matching your form field names:

Google Sheet (Row 1)
| Timestamp | name | email | message |

Step 2: Add the Apps Script

In your Sheet, go to Extensions โ†’ Apps Script. Replace the default code with this:

Code.gs (Apps Script)
const SHEET_NAME = "Sheet1"; // Change to your sheet tab name

function doPost(e) {
  try {
    const sheet = SpreadsheetApp.getActiveSpreadsheet()
                    .getSheetByName(SHEET_NAME);

    // Parse incoming JSON or form-encoded data
    let data;
    if (e.postData && e.postData.type === "application/json") {
      data = JSON.parse(e.postData.contents);
    } else {
      data = e.parameter;
    }

    // Build a row: timestamp + all field values
    const headers = sheet.getRange(1, 1, 1, sheet.getLastColumn())
                         .getValues()[0];
    const row = headers.map(header => {
      if (header === "Timestamp") return new Date().toISOString();
      return data[header] || "";
    });

    sheet.appendRow(row);

    // Return CORS-friendly success response
    return ContentService
      .createTextOutput(JSON.stringify({ status: "ok" }))
      .setMimeType(ContentService.MimeType.JSON);

  } catch (err) {
    return ContentService
      .createTextOutput(JSON.stringify({ status: "error", message: err.message }))
      .setMimeType(ContentService.MimeType.JSON);
  }
}

// Required for CORS preflight
function doGet(e) {
  return ContentService
    .createTextOutput(JSON.stringify({ status: "ok" }))
    .setMimeType(ContentService.MimeType.JSON);
}

Step 3: Deploy as a Web App

Click "Deploy" โ†’ "New Deployment"

In the Apps Script editor, click the blue "Deploy" button in the top right.

Set type to "Web app"

Click the gear icon next to "Type" and select Web app.

Set Execute as "Me" and Who has access to "Anyone"

This is required for your form to submit without users needing a Google account.

Authorize and copy the URL

Click "Deploy", authorize the permissions, and copy the Web app URL โ€” it looks like https://script.google.com/macros/s/.../exec.

Step 4: Connect your HTML form

index.html
<form id="contact-form">
  <input name="name" type="text" placeholder="Your name" required>
  <input name="email" type="email" placeholder="Email" required>
  <textarea name="message" placeholder="Message" required></textarea>
  <button type="submit" id="submit-btn">Send Message</button>
</form>

<script>
const APPS_SCRIPT_URL = "https://script.google.com/macros/s/YOUR_ID/exec";

document.getElementById("contact-form").addEventListener("submit", async (e) => {
  e.preventDefault();
  const btn = document.getElementById("submit-btn");
  btn.textContent = "Sending...";
  btn.disabled = true;

  const formData = Object.fromEntries(new FormData(e.target));

  try {
    const res = await fetch(APPS_SCRIPT_URL, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(formData),
      // Apps Script requires redirect:follow due to a server-side redirect
      redirect: "follow",
      mode: "no-cors",  // Required โ€” Apps Script doesn't send CORS headers on redirects
    });

    // mode:no-cors means response is "opaque" โ€” we assume success
    btn.textContent = "Sent! โœ“";
    e.target.reset();
  } catch (err) {
    btn.textContent = "Error โ€” try again";
    btn.disabled = false;
  }
});
</script>
โš ๏ธ
The mode: "no-cors" gotcha: Apps Script redirects to a different URL which loses CORS headers. You must use mode: "no-cors", which means the response is "opaque" โ€” you can't read success/error status from JavaScript. You're flying blind. This is the biggest drawback of the Apps Script approach.

Common Apps Script Problems

Method 2: LaunchQ + Google Sheets Webhook (Recommended)
2-minute setup Email notifications Spam protection Dashboard included

LaunchQ is a form backend that handles your submissions, sends email notifications, stores data, detects spam โ€” and webhooks every submission to anywhere you want, including Google Sheets (via a simple webhook bridge).

The advantage: your form gets email notifications, AI spam filtering, a submission dashboard, AND goes into Google Sheets โ€” without writing any server-side code.

Step 1: Create a LaunchQ form

Sign up for LaunchQ (free tier: 100 submissions/mo, 3 forms). Create a new form and copy your endpoint URL.

Your LaunchQ endpoint
https://formbox.gibby-workspace.com/f/your-form-id

Step 2: Add your form

index.html
<!-- Option A: Plain HTML form (redirect on submit) -->
<form action="https://formbox.gibby-workspace.com/f/your-form-id" method="POST">
  <input name="name" type="text" placeholder="Your name" required>
  <input name="email" type="email" placeholder="Email" required>
  <textarea name="message" placeholder="Message" required></textarea>
  <input type="hidden" name="_redirect" value="https://yoursite.com/thanks">
  <button type="submit">Send Message</button>
</form>

<!-- Option B: AJAX (no page reload) -->
<script>
document.getElementById("contact-form").addEventListener("submit", async (e) => {
  e.preventDefault();
  const res = await fetch("https://formbox.gibby-workspace.com/f/your-form-id", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(Object.fromEntries(new FormData(e.target)))
  });
  const data = await res.json();
  if (data.success) {
    // Show success message
  }
});
</script>

Step 3: Connect Google Sheets via Webhook

In your LaunchQ form settings, go to Webhooks and add a webhook URL. You can use one of these free bridge options:

Option A: Pipedream (free, no-code)

Pipedream Workflow
1. Go to pipedream.com โ†’ New Workflow
2. Add trigger: HTTP / Webhook
3. Add step: Google Sheets โ†’ "Add Row to Sheet"
4. Map LaunchQ fields to sheet columns
5. Copy the webhook URL โ†’ paste into LaunchQ webhook settings

Option B: Make (Integromat) โ€” free tier

Make Scenario
1. Create scenario: Webhooks (trigger) โ†’ Google Sheets (add row)
2. Get your unique webhook URL
3. Add it to LaunchQ form settings โ†’ Webhooks
4. Every LaunchQ submission โ†’ instantly appears in your Sheet

Option C: Google Apps Script webhook (best of both worlds)

Use your Apps Script URL from Method 1 as a LaunchQ webhook โ€” LaunchQ sends a proper server-to-server POST request, bypassing the CORS issue entirely:

LaunchQ Webhook Settings
Webhook URL: https://script.google.com/macros/s/YOUR_ID/exec
Method: POST
Content-Type: application/json

// LaunchQ sends:
{
  "name": "Jane Smith",
  "email": "jane@example.com",
  "message": "Hello!",
  "submitted_at": "2026-02-24T12:00:00Z",
  "form_id": "your-form-id",
  "spam_score": 0.02
}
โœ…
Why this approach is better: LaunchQ's server talks to Apps Script server-to-server โ€” no CORS restrictions, no no-cors hacks, proper error/retry handling. Your form also gets email notifications and an admin dashboard automatically.
Method 3: Zapier / Make (No-code)
No code Paid after free tier 300+ integrations

If you're not comfortable writing code, Zapier and Make (formerly Integromat) can connect your form to Google Sheets visually.

Using Zapier

Zapier Zap Setup
Trigger: Webhooks by Zapier (catch hook)
Action:  Google Sheets โ†’ Create Spreadsheet Row

1. Create a Zap with the Webhook trigger
2. Copy your unique Zapier webhook URL
3. Use it as your form's action URL:
   <form action="https://hooks.zapier.com/hooks/catch/YOUR_ID/" method="POST">
4. Connect to Google Sheets, map fields to columns
5. Turn on the Zap
โ„น๏ธ
Zapier pricing: Free tier allows 100 tasks/month (100 form submissions โ†’ Sheets rows). Starter plan ($19.99/mo) for 750 tasks. Zapier is expensive for high-volume forms.

Using Make (free tier: 1,000 ops/mo)

Make Scenario
Module 1: Webhooks โ†’ Custom Webhook (trigger)
Module 2: Google Sheets โ†’ Add a Row

Steps:
1. Create a scenario in Make
2. Add "Custom Webhook" trigger โ†’ copy URL
3. Set <form action="YOUR_MAKE_WEBHOOK_URL" method="POST">
4. Run once to capture field structure
5. Add Google Sheets โ†’ Add Row module
6. Map captured fields to sheet columns

React & Next.js Form to Google Sheets

For React and Next.js apps, you can't use plain HTML form action. Here's the async approach using LaunchQ (recommended) or Apps Script directly.

React: Contact Form with LaunchQ

ContactForm.jsx
import { useState } from 'react';

const FORMBOX_ENDPOINT = "https://formbox.gibby-workspace.com/f/your-form-id";

export function ContactForm() {
  const [status, setStatus] = useState('idle'); // idle | loading | success | error

  async function handleSubmit(e) {
    e.preventDefault();
    setStatus('loading');

    const data = Object.fromEntries(new FormData(e.target));

    try {
      const res = await fetch(FORMBOX_ENDPOINT, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(data),
      });
      const result = await res.json();
      setStatus(result.success ? 'success' : 'error');
    } catch {
      setStatus('error');
    }
  }

  if (status === 'success') {
    return (
      <div className="success-message">
        Thanks! We'll be in touch soon. ๐ŸŽ‰
      </div>
    );
  }

  return (
    <form onSubmit={handleSubmit}>
      <input name="name" type="text" placeholder="Name" required />
      <input name="email" type="email" placeholder="Email" required />
      <textarea name="message" placeholder="Message" required />
      <button type="submit" disabled={status === 'loading'}>
        {status === 'loading' ? 'Sending...' : 'Send Message'}
      </button>
      {status === 'error' && (
        <p className="error">Something went wrong. Please try again.</p>
      )}
    </form>
  );
}

Next.js App Router: Server Action

Next.js 13+ Server Actions let you handle form submissions server-side without a separate API route:

app/contact/page.tsx
'use server'

// Server Action โ€” runs on the server, no client-side JS needed
async function submitToFormBox(formData: FormData) {
  const data = {
    name: formData.get('name'),
    email: formData.get('email'),
    message: formData.get('message'),
  };

  const res = await fetch('https://formbox.gibby-workspace.com/f/your-form-id', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify(data),
  });

  if (!res.ok) throw new Error('Submission failed');
}

// Page component
export default function ContactPage() {
  return (
    <form action={submitToFormBox}>
      <input name="name" type="text" required />
      <input name="email" type="email" required />
      <textarea name="message" required />
      <button type="submit">Send Message</button>
    </form>
  );
}

Method Comparison

Feature Apps Script DIY LaunchQ + Webhook Zapier/Make
Setup time 30โ€“60 min 2 min 15โ€“30 min
Cost Free Free (100/mo) Free tier limited
Email notifications โŒ DIY โœ… Built-in โŒ Extra step
Spam protection โŒ None โœ… AI-powered โŒ None
Submission dashboard โŒ โœ… Built-in โŒ
CORS issues โš ๏ธ Always โœ… None โœ… None
Reliability โš ๏ธ Brittle โœ… Excellent โœ… Good
Error handling โŒ Opaque (no-cors) โœ… Full JSON response โš ๏ธ Webhook only
File uploads โŒ โœ… (Starter+) โŒ
Scales to 10k+/mo โš ๏ธ Quota limits โœ… Yes ๐Ÿ’ฐ Expensive

Common Problems & Fixes

CORS error when submitting to Apps Script

Fix
// Use mode: "no-cors" AND redirect: "follow"
const res = await fetch(APPS_SCRIPT_URL, {
  method: "POST",
  body: JSON.stringify(data),
  mode: "no-cors",     // โ† Required
  redirect: "follow",  // โ† Required
});

Form submits but data doesn't appear in sheet

Double submissions on fast networks

Fix: Disable button on submit
document.getElementById("submit-btn").disabled = true;

// Re-enable after response (or on error):
res.catch(() => {
  document.getElementById("submit-btn").disabled = false;
});

Apps Script stops working after a few weeks

This is the #1 complaint with DIY Apps Script setups. The fix is to re-deploy as a new deployment (not "manage existing deployments"). The deployment URL changes each time, which means updating your form. This is why LaunchQ + webhook is more reliable long-term.

๐Ÿš€ Skip the Apps Script headaches

LaunchQ handles form submissions, email notifications, and webhooks to Google Sheets โ€” all in 2 minutes. Free tier includes 100 submissions/month.

Start Free โ€” No Credit Card

Frequently Asked Questions

Can I send form data to multiple Google Sheets?

With Apps Script: add logic to getSheetByName() and write to multiple sheets in one doPost call. With LaunchQ: add multiple webhooks pointing to different Pipedream/Make workflows, each writing to a different Sheet.

Does this work with Google Forms?

Google Forms has its own Sheets integration built in โ€” no code needed. But Google Forms has a fixed, generic UI. If you want a custom-designed form on your own website (HTML, React, Vue), you need one of the 3 methods above.

Is the Apps Script approach secure?

Somewhat. Anyone with your Apps Script URL can send data to your Sheet. Add validation in your doPost function (check for required fields, validate email format). For production forms, use spam protection โ€” either reCAPTCHA v3 or AI spam detection (built into LaunchQ).

Can I use this with WordPress, Webflow, or Squarespace?

Yes. Any platform that lets you add custom HTML supports all 3 methods. Webflow and Framer: embed a custom HTML form element. Squarespace: use a Code Block. WordPress: embed in a Custom HTML block or use a theme that allows raw HTML.

What's the Apps Script execution quota?

Free Google accounts: 90 minutes of execution time per day, 6-minute max per execution. Each form submission takes ~0.1-0.5 seconds. That's roughly 10,000-50,000 submissions/day before hitting quota. For most contact forms, this is plenty.

Can I format data in the Sheet (dates, phone numbers)?

In Apps Script, you can transform values in doPost before calling appendRow. Example: new Date(data.submitted_at).toLocaleDateString(). With Pipedream/Make, use formatter steps to transform values before writing to Sheets.

Related Articles

Share this article:

๐Ÿฆ Share on X ๐Ÿ”ถ Share on HN