โก TL;DR โ Skip to what you need
- Method 1: Google Apps Script โ Free, but fragile and hard to maintain
- Method 2: LaunchQ + Sheets webhook โ Easiest, production-ready, 2 minutes to set up
- Method 3: Zapier / Make โ No-code, great for non-developers
- Comparison table โ Which method is right for you?
- React & Next.js โ Framework-specific examples
- Common problems โ CORS, CSRF, double submissions
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.
<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.
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:
| Timestamp | name | email | message |
Step 2: Add the Apps Script
In your Sheet, go to Extensions โ Apps Script. Replace the default code with this:
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
<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>
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
- Submissions stop working after a few days โ Google re-authorizes on quota resets; redeploy as a new deployment
- CORS errors โ Always use
mode: "no-cors"andredirect: "follow" - Script timeout (6 min limit) โ Fine for contact forms, problem for bulk imports
- "You do not have permission" โ Re-deploy and re-authorize; set "Anyone" access again
- Column headers must match exactly โ The script reads headers from row 1; a trailing space breaks it
- Can't tell if it worked โ
no-corsgives opaque responses; use LaunchQ or a webhook for proper error handling
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.
https://formbox.gibby-workspace.com/f/your-form-id
Step 2: Add your form
<!-- 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)
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
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:
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
}
no-cors hacks, proper error/retry handling. Your form also gets email notifications and an admin dashboard automatically.
If you're not comfortable writing code, Zapier and Make (formerly Integromat) can connect your form to Google Sheets visually.
Using Zapier
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
Using Make (free tier: 1,000 ops/mo)
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
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:
'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
// 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
- Check column headers in row 1 match your field names exactly (case-sensitive)
- Re-deploy the Apps Script as a new deployment (authorization may have expired)
- Confirm "Who has access: Anyone" โ not "Anyone with a Google account"
- Check Apps Script execution log: Extensions โ Apps Script โ Executions
Double submissions on fast networks
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.
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
- How to Send HTML Form to Email (No Backend)
- Connect HTML Forms to Zapier & Make Webhooks
- Add a Contact Form to Any Static Website
- Best FormSpree Alternatives for 2026
Share this article: