Back to blog
·7 min read·Google SheetsApps ScriptContact FormNo Backend

The Free, Unlimited Contact Form Backend Hiding in Your Google Account

Stop paying per-submission. A Google Sheet plus a 12-line script is the spreadsheet-as-backend trick powering my own portfolio. Here's how to wire one up in under an hour.

Every portfolio needs a contact form. None of them need a backend.

That's the lie most tutorials sell you: that to receive a name, an email, and a message, you need a server, a database, an SMTP provider, and a paid plan once you cross some submission cap. You don't. You need a Google Sheet.

This post is the one I wish I'd read before I shipped my own portfolio. It's the exact setup powering the "Let's Connect" form on this very site. No Vercel function, no third-party SaaS, no monthly limit, no API key to leak. Just a spreadsheet, twelve lines of Apps Script, and a single fetch.

The five-tool problem

If you searched "free contact form for Next.js" recently, you found these:

Web3Forms
Devs who want it simple
250 / month
EmailJS
Sending email from the client
200 / month
FormSubmit
Zero-account, zero-setup
Unlimited (basic)
Formspree
Industry standard
50 / month
Netlify Forms
Sites already on Netlify
100 / month
Google Sheets + Apps Script
Owning your data, full control
unlimited

They're all fine. They all work. But every one of them is a vendor between you and your own data, with a meter ticking. Cross 50 submissions a month on Formspree, 100 on Netlify, 200 on EmailJS, and you're either upgrading or watching messages get dropped on the floor.

A Google Sheet has no meter. It is your data, in a format you already know how to read, sort, filter, and share. It just needs a tiny doorway.

The unlock: Google Apps Script can deploy any .gs function as a public web URL in two clicks. That URL becomes the doorway your form fetches. The function on the other side appends a row to a Sheet. That's the entire backend.

What we're actually building

Three pieces, talking to each other:

how a submission travels
Browser
fetch( ... )
Apps Script
doGet(e)
Google Sheet
appendRow(...)
name · email · message · timestampone new row, every time

The browser makes a request. Apps Script catches it, picks the fields out of the URL, and writes one row to a Sheet you own. Done. No databases to provision, no servers to keep warm, no API keys to rotate.

Now let's wire it up.

Step 1: Create the Sheet

Go to sheets.new (yes, that's a real shortcut). Name it something like contact-submissions. In row 1, drop your column headers. These become your schema:

contact-submissions · Google Sheets
ABCD
1TimestampNameEmailMessage
22026-05-07 10:42Rupalirupali@vevaar.comLoved the X-Fathom extension, open to a chat?
32026-05-07 11:08Rupalirupali@vevaar.comHiring for a creative FE role, you free this week?
42026-05-07 12:21Rupalirupali@vevaar.comNeed a polished portfolio rebuild. Budget ready.
52026-05-07 13:55Rupalirupali@vevaar.comQuick question about your timeline section.
rows arrive in real time — no refresh, no webhook, no server

I keep four columns: Timestamp, Name, Email, Message. Add more later if you want: phone, source, referrer, whatever. The headers are just for you; the script writes by column position, not by name.

That's the database. Move on.

Step 2: Open Apps Script

From inside the Sheet: Extensions → Apps Script. A new tab opens with a code editor that already has your sheet bound to it.

Delete the boilerplate function myFunction() {} and paste this:

function doGet(e) {
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
  const p = e.parameter;

  sheet.appendRow([
    p.timestamp || new Date().toISOString(),
    p.name || "",
    p.email || "",
    p.message || "",
  ]);

  return ContentService
    .createTextOutput(JSON.stringify({ ok: true }))
    .setMimeType(ContentService.MimeType.JSON);
}

That's it. That's the whole backend.

A couple of things to notice:

  • doGet(e) is a reserved name. Apps Script automatically routes incoming GET requests to it. There's a doPost too, but GET is simpler for this use case (more on that in Step 4).
  • e.parameter is the parsed query string. Send ?name=Rupali&email=rupali@vevaar.com and you get { name: "Rupali", email: "rupali@vevaar.com" }.
  • appendRow writes a new row at the bottom of the sheet. Position-based, not key-based, so the order of the array matters.

Hit Save (Cmd/Ctrl + S). Name the project anything. contact-form-backend works.

Step 3: Deploy as a Web App

This is the one slightly fiddly step. Top-right of the editor: Deploy → New deployment.

  • Type (gear icon) → Web app
  • Description → anything
  • Execute asMe
  • Who has accessAnyone

Click Deploy. Google asks you to authorize the script. That's because it's about to run as you and write to your sheet. Approve it.

You'll get back a URL like:

https://script.google.com/macros/s/AKfycb...EXAMPLE.../exec

Copy it. That's your doorway. Treat it like a public endpoint, because it is one.

Anyone-with-the-link can hit this URL. That's the point: your form needs to. But it also means a determined bot can. We'll talk about cheap spam protection at the end. For a personal portfolio, the volume is low enough that this rarely matters in practice.

Step 4: Hook your form to the URL

Back in your Next.js (or any) app, the entire client-side submit handler looks like this:

const SCRIPT_URL =
  "https://script.google.com/macros/s/YOUR_DEPLOYMENT_ID/exec";

async function submit({ name, email, message }) {
  const url =
    `${SCRIPT_URL}` +
    `?name=${encodeURIComponent(name)}` +
    `&email=${encodeURIComponent(email)}` +
    `&message=${encodeURIComponent(message)}` +
    `&timestamp=${encodeURIComponent(new Date().toISOString())}`;

  await fetch(url, { method: "GET", mode: "no-cors", cache: "no-cache" });
}

Submit the form. Switch to your Google Sheet tab. The row is already there.

Two things in that snippet earn an explanation, because they're the clever bits that make the whole approach work without any server-side code on your side:

Why GET, not POST?

Apps Script supports both. But POST with a JSON body forces the browser to send a CORS preflight (OPTIONS) request, and Apps Script's response doesn't include the CORS headers a preflight needs. You'd get an error before your real request ever fires.

GET with query parameters skips the preflight entirely. Simpler request, no headers to argue about, works everywhere.

Why mode: "no-cors"?

It tells the browser: "I know I can't read this response. Send the request anyway." The browser fires it off, the server (Apps Script) receives it and writes the row, and the response comes back as opaque. Your code can't inspect status codes or response bodies.

That sounds bad. For a contact form, it isn't. You don't need the response. You just need the row to land. The Sheet is your confirmation.

Two-line summary for future-you:

  • GET with query params → no CORS preflight, no headers to fight.
  • mode: "no-cors" → fire-and-forget, opaque response, doorway opens cleanly.

What you actually get for free

0 $monthly cost
0lines of backend code
0Google account needed
0Sheet row capacity (Google's cap)

That last number is real. Google Sheets caps at 10 million cells per spreadsheet. With four columns, that's 2.5 million submissions before you'd need a second sheet. For comparison, Formspree's free tier would let you collect 50 a month. You'd hit their cap in a single afternoon of going viral; you'd hit Google's after running this form for ~4,000 years.

But wait, the spreadsheet is way more than a database

Here's the part most people miss. You didn't just get a backend. You got:

  • A real-time inbox. Open the sheet on your phone. Submissions appear as rows. Done.
  • A CRM, sort of. Add a Status column. Mark rows Replied, Meeting booked, Ignored. Filter views.
  • An email trigger, in two clicks. Tools → Notification settings → Notify me → Any changes → Right away. Now every new submission emails you.
  • A Slack/Discord webhook, in five lines. Inside Apps Script, after appendRow, call UrlFetchApp.fetch(SLACK_WEBHOOK, { ... }). Free. No Zapier.
  • A monthly digest, in a Time-driven trigger. Apps Script has cron. You can wake up every Monday at 9am, scan new rows, and email yourself a summary.
  • A Looker Studio dashboard. Right-click → Explore → instant chart. Free. Always live.

You got all of that because you stored your data in a tool that was designed for humans to look at, not just for machines to query. That's the real unlock.

Honest tradeoffs

Be realistic about where this approach hurts:

  • No real success/failure signal on the client. With mode: "no-cors" you can't tell if the row landed. For a contact form this is fine. Show a generic "Thanks!" toast. For an e-commerce checkout, absolutely not.
  • Apps Script cold start. First request after a quiet period takes ~1-2 seconds. Animate your submit button so users feel something is happening.
  • Spam. A public URL accepting GET requests will eventually get bot traffic. Cheap defenses, in increasing order of effort:
    1. Add a hidden "honeypot" input. If it's filled, drop the row.
    2. Add a secret query param the script checks before writing.
    3. Add hCaptcha or Cloudflare Turnstile (still free) on the client.
  • URL length limit. Browsers cap URLs around 8KB. Don't accept full essays in message. Or switch to POST (and accept the CORS dance).
  • It's still your Google account. Lose access to it, lose the form. Worth it for personal projects; may not be for client work.

Where to take it next

Once the basic setup lands, every upgrade is an Apps Script edit away:

// Inside doGet, after appendRow:

// 1. Slack notification
UrlFetchApp.fetch(SLACK_WEBHOOK_URL, {
  method: "post",
  contentType: "application/json",
  payload: JSON.stringify({ text: `New: ${p.name} (${p.email})` }),
});

// 2. Auto-reply email
MailApp.sendEmail({
  to: p.email,
  subject: "Got your message!",
  body: `Hi ${p.name}, I'll get back to you in 24 hours.`,
});

// 3. Honeypot spam guard
if (p.website) return; // bots fill hidden "website" fields, humans don't

Three features. Zero new dependencies. Still the same Sheet.

The takeaway

You didn't need a backend. You needed a place to put the data and a doorway for the data to get there. A Google Sheet is the place. Twelve lines of Apps Script is the doorway. Everything else (the SaaS dashboards, the per-submission billing, the API keys) is overhead someone else is selling you.

If you've been putting off shipping a contact form because it felt like too much infrastructure: you're forty-five minutes away. Open a sheet. Paste the script. Hit deploy.

The rest of your portfolio is harder than this part.