TL;DR: Mit Google Apps Script kannst du jedes Google Spreadsheet in ein kostenloses Formular-Backend verwandeln – ganz ohne Server, ohne Drittanbieter-Abo und mit voller TypeScript-Typsicherheit. Der einzige Haken: CORS. Aber auch dafĂŒr gibt’s Lösungen.

Du brauchst ein Kontaktformular fĂŒr deine Seite und dein erster Gedanke ist Formspree, Typeform oder ein eigener Backend-Service? VerstĂ€ndlich – aber hast du mal daran gedacht, einfach ein Google Spreadsheet als Backend zu nehmen? Klingt erstmal wild, funktioniert aber ĂŒberraschend gut. In diesem Artikel zeige ich dir, wie du das Ganze aufsetzt.

đŸ€” Warum ausgerechnet ein Spreadsheet?

Ganz ehrlich: FĂŒr die meisten Kontaktformulare, Wartelisten oder Feedback-Sammler brauchst du kein volles Backend. Ein Spreadsheet bringt alles mit, was du brauchst:

  • Kostenlos – kein Hosting, keine Abos, nada
  • Sofort teilbar – Kollegen können die EintrĂ€ge direkt im Sheet sehen
  • Sortierung, Filter, Diagramme – alles schon eingebaut
  • Kein Datenbank-Setup – das Spreadsheet ist deine Datenbank

✏ Schritt 1: Spreadsheet vorbereiten

Erstelle ein neues Google Spreadsheet und definiere in der ersten Zeile die Header, die zu deinen Formularfeldern passen:

ABCD
TimestampNameEmailMessage

Wichtig: Merk dir den Sheet-Namen (Standard ist "Sheet1") – den brauchst du gleich im Script.

🚀 Schritt 2: Das Apps Script aufsetzen

Jetzt wird’s spannend. Öffne dein Spreadsheet, geh auf Erweiterungen → Apps Script und ersetze den Default-Code durch Folgendes:

const SHEET_NAME = "Sheet1";

function doPost(e) {
  const lock = LockService.getScriptLock();
  lock.tryLock(10000);

  try {
    const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(SHEET_NAME);
    const data = JSON.parse(e.postData.contents);

    const timestamp = new Date().toISOString();
    sheet.appendRow([timestamp, data.name, data.email, data.message]);

    return ContentService
      .createTextOutput(JSON.stringify({ result: "success", row: sheet.getLastRow() }))
      .setMimeType(ContentService.MimeType.JSON);
  } catch (error) {
    return ContentService
      .createTextOutput(JSON.stringify({ result: "error", message: error.toString() }))
      .setMimeType(ContentService.MimeType.JSON);
  } finally {
    lock.releaseLock();
  }
}

// Optional: GET-Requests zum Testen
function doGet() {
  return ContentService
    .createTextOutput(JSON.stringify({ status: "ok", message: "Form endpoint is running" }))
    .setMimeType(ContentService.MimeType.JSON);
}

Was hier passiert, ist relativ straightforward: doPost(e) nimmt den JSON-Body entgegen, parst ihn und hĂ€ngt eine neue Zeile ans Sheet. Der LockService sorgt dafĂŒr, dass bei gleichzeitigen Requests keine Daten verloren gehen.

⚙ Schritt 3: Als Web App deployen

  1. Klick auf Bereitstellen → Neue Bereitstellung
  2. Typ: Web-App
  3. AusfĂŒhren als: Ich
  4. Zugriff: Jeder
  5. Bereitstellen → App autorisieren → URL kopieren

Die URL sieht dann so aus: https://script.google.com/macros/s/AKfycb.../exec

Achtung: Bei jeder Änderung am Script musst du eine neue Bereitstellung erstellen oder die bestehende aktualisieren. Sonst laufen deine Requests weiter gegen die alte Version.

đŸ’» Schritt 4: Der TypeScript Form Handler

Jetzt kommt der Frontend-Teil. Hier ist ein sauberer, typsicherer Handler, den du in jedes Framework oder auch Vanilla JS/TS droppen kannst:

interface FormData {
  name: string;
  email: string;
  message: string;
}

interface AppsScriptResponse {
  result: "success" | "error";
  row?: number;
  message?: string;
}

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

async function submitToSheet(data: FormData): Promise<AppsScriptResponse> {
  const response = await fetch(APPS_SCRIPT_URL, {
    method: "POST",
    mode: "no-cors",
    headers: {
      "Content-Type": "text/plain",
    },
    body: JSON.stringify(data),
  });

  return { result: "success" };
}

const form = document.querySelector<HTMLFormElement>("#contact-form");

form?.addEventListener("submit", async (e) => {
  e.preventDefault();

  const formData: FormData = {
    name: (document.getElementById("name") as HTMLInputElement).value,
    email: (document.getElementById("email") as HTMLInputElement).value,
    message: (document.getElementById("message") as HTMLTextAreaElement).value,
  };

  try {
    await submitToSheet(formData);
    form.reset();
    alert("Erfolgreich gesendet!");
  } catch (error) {
    console.error("Senden fehlgeschlagen:", error);
    alert("Etwas ist schiefgelaufen. Bitte versuche es erneut.");
  }
});

Dir fÀllt wahrscheinlich auf: mode: "no-cors" und Content-Type: "text/plain". Warum? Dazu kommen wir jetzt.

⚠ Das CORS-Problem – und drei Wege drumherum

Hier kommt der Teil, an dem die meisten scheitern: Google Apps Script kann keine CORS-Preflight-Requests (OPTIONS) verarbeiten. Das heißt konkret:

  • Content-Type: "application/json" löst einen Preflight aus → Request schlĂ€gt fehl
  • mode: "no-cors" funktioniert, aber du kannst den Response-Body nicht lesen

Je nach Use Case hast du drei Optionen:

Option A: no-cors (der pragmatische Weg)

Du schickst mit text/plain und no-cors. Die Daten kommen an, du bekommst nur keinen Response zurĂŒck. FĂŒr ein simples Kontaktformular ist das völlig okay.

Option B: Redirect-Workaround

Falls du die Antwort brauchst: Sende ĂŒber einen versteckten <iframe> oder nutze redirect: "follow". Etwas mehr Aufwand, aber machbar.

Option C: GET statt POST (mein Favorit fĂŒr einfache Forms)

Bau das Apps Script so um, dass es GET mit Query-Parametern verarbeitet. Dann hast du keine CORS-Probleme und bekommst den Response:

function doGet(e) {
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(SHEET_NAME);
  const params = e.parameter;

  sheet.appendRow([
    new Date().toISOString(),
    params.name,
    params.email,
    params.message,
  ]);

  return ContentService
    .createTextOutput(JSON.stringify({ result: "success" }))
    .setMimeType(ContentService.MimeType.JSON);
}

Und im Frontend wird’s dann ganz simpel:

async function submitViaGet(data: FormData): Promise<AppsScriptResponse> {
  const params = new URLSearchParams(data as Record<string, string>);
  const response = await fetch(`${APPS_SCRIPT_URL}?${params}`);
  return response.json();
}

🎹 Schritt 5: Das HTML-Formular

Das Formular selbst ist bewusst simpel gehalten – du kannst es nach Belieben stylen:

<form id="contact-form">
  <div>
    <label for="name">Name</label>
    <input type="text" id="name" name="name" required />
  </div>
  <div>
    <label for="email">Email</label>
    <input type="email" id="email" name="email" required />
  </div>
  <div>
    <label for="message">Nachricht</label>
    <textarea id="message" name="message" rows="5" required></textarea>
  </div>
  <button type="submit">Senden</button>
</form>

🔧 Bonus: Serverseitige Validierung

Klar, Client-Validierung ist nett – aber du willst dich nicht drauf verlassen. Hier eine erweiterte doPost-Variante mit Validierung:

function doPost(e) {
  const lock = LockService.getScriptLock();
  lock.tryLock(10000);

  try {
    const data = JSON.parse(e.postData.contents);

    if (!data.email || !data.email.includes("@")) {
      return ContentService
        .createTextOutput(JSON.stringify({ result: "error", message: "UngĂŒltige E-Mail" }))
        .setMimeType(ContentService.MimeType.JSON);
    }

    if (!data.name || data.name.trim().length < 2) {
      return ContentService
        .createTextOutput(JSON.stringify({ result: "error", message: "Name ist erforderlich" }))
        .setMimeType(ContentService.MimeType.JSON);
    }

    const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(SHEET_NAME);
    sheet.appendRow([new Date().toISOString(), data.name.trim(), data.email.trim(), data.message]);

    return ContentService
      .createTextOutput(JSON.stringify({ result: "success" }))
      .setMimeType(ContentService.MimeType.JSON);
  } catch (error) {
    return ContentService
      .createTextOutput(JSON.stringify({ result: "error", message: error.toString() }))
      .setMimeType(ContentService.MimeType.JSON);
  } finally {
    lock.releaseLock();
  }
}

📧 Bonus: E-Mail-Benachrichtigung bei neuen EintrĂ€gen

Du willst sofort wissen, wenn jemand das Formular abschickt? FĂŒg einfach diese Zeile nach appendRow ein:

MailApp.sendEmail({
  to: "[email protected]",
  subject: `Neue Formular-Einreichung von ${data.name}`,
  htmlBody: `
    <h3>Neue Kontaktformular-Einreichung</h3>
    <p><strong>Name:</strong> ${data.name}</p>
    <p><strong>Email:</strong> ${data.email}</p>
    <p><strong>Nachricht:</strong> ${data.message}</p>
  `,
});

đŸ›Ąïž Was du bei der Sicherheit beachten solltest

Ein paar Dinge, die du im Hinterkopf behalten solltest:

  • Formula Injection: Wenn ein Wert mit =, +, - oder @ anfĂ€ngt, kann Google Sheets das als Formel interpretieren. Stelle solche Werte mit einem Apostroph (') voran
  • Rate Limiting: Apps Script hat eingebaute Quotas (~20.000 Aufrufe/Tag), aber fĂŒr Spam-Schutz solltest du trotzdem ĂŒber eigene Limits nachdenken
  • Honeypot-Felder: FĂŒg ein verstecktes Feld ein – wenn ein Bot es ausfĂŒllt, lehnst du die Submission ab
  • Keine sensiblen Daten lesen: Die Apps-Script-URL ist öffentlich. Nutze sie nur zum Schreiben, nicht zum Lesen sensibler Daten

💡 Fazit

Google Spreadsheets + Apps Script ist ein Setup, das ich fĂŒr einfache Formulare mittlerweile wirklich gerne nutze. Kostenlos, kein Server-Overhead, und die Daten landen direkt in einem Sheet, das du mit deinem Team teilen kannst.

Der einzige Stolperstein ist CORS – aber mit der GET-Variante oder no-cors hast du das schnell im Griff. Der gesamte Code ist framework-agnostisch: React, Angular, Vue, Svelte oder plain HTML – alles kein Problem.

Artikel teilen:Share article:

Wie fandest du diesen Artikel?How did you like this article?

Mehr Artikel entdecken

CSS variables: Flexible styling for your components 🎹
Vorheriger ArtikelPrevious article

CSS variables: Flexible styling for your components 🎹

CSS variables make your styling more flexible 🎯 In this guide, I'll explain how to use, scope and override them in components! 🌈

3 min read ‱ 28. MĂ€rz 2025MĂ€rz 28, 2025
Angular input() fĂŒr Route-Parameter: Schluss mit ActivatedRoute 🚀
NĂ€chster ArtikelNext article

Angular input() fĂŒr Route-Parameter: Schluss mit ActivatedRoute 🚀

Mit Angular 16+ kannst du Route-Parameter direkt per input() in deine Komponente binden – ganz ohne ActivatedRoute. So geht's!

3 min read ‱ 10. MĂ€rz 2026MĂ€rz 10, 2026
Connecting Custom Forms to Google Spreadsheets with Apps Script 📊
Ähnlicher ArtikelSimilar article

Connecting Custom Forms to Google Spreadsheets with Apps Script 📊

Turn any Google Spreadsheet into a free form backend with Google Apps Script – no server needed, with TypeScript type safety and CORS solutions.

5 min read ‱ 10. MĂ€rz 2026MĂ€rz 10, 2026
Mutation Observer: The invisible force in the background of your website đŸ•”ïž
Ähnlicher ArtikelSimilar article

Mutation Observer: The invisible force in the background of your website đŸ•”ïž

The Mutation Observer is your invisible helper in the background, monitoring DOM changes in real time. Learn how to use it! đŸ•”ïž

4 min read ‱ 31. Aug. 2024Aug. 31, 2024
Mutation Observer: Die unsichtbare Kraft im Hintergrund deiner Webseite đŸ•”ïž
Ähnlicher ArtikelSimilar article

Mutation Observer: Die unsichtbare Kraft im Hintergrund deiner Webseite đŸ•”ïž

Der Mutation Observer ist dein unsichtbarer Helfer im Hintergrund, der DOM-Änderungen in Echtzeit ĂŒberwacht. Lerne, wie du ihn einsetzen kannst! đŸ•”ïž

4 min read ‱ 31. Aug. 2024Aug. 31, 2024
Intersection Observer: A powerful tool for efficient web design 🚀
Ähnlicher ArtikelSimilar article

Intersection Observer: A powerful tool for efficient web design 🚀

Discover how the Intersection Observer makes your websites more efficient and why it should be an indispensable tool in your arsenal! 🚀

8 min read ‱ 24. Aug. 2024Aug. 24, 2024