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:

Mehr Artikel entdecken

CSS variables: Flexible styling for your components 🎨
Vorheriger Artikel

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 2025
Angular input() für Route-Parameter: Schluss mit ActivatedRoute 🚀
Nächster Artikel

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 2026
Vue.js: Moderner Ansatz für die Frontend-Entwicklung 🚀
Ähnlicher Artikel

Vue.js: Moderner Ansatz für die Frontend-Entwicklung 🚀

Erfahre alles über Vue.js und wie du mit diesem leistungsstarken JavaScript-Framework beeindruckende Webanwendungen erstellen kannst. 🚀

5 min read • 28. März 2026
jQuery ist tot: Warum Du es sofort aus Deinen Projekten entfernen solltest 🚀
Ähnlicher Artikel

jQuery ist tot: Warum Du es sofort aus Deinen Projekten entfernen solltest 🚀

Erfahre, warum jQuery in modernen Webprojekten oft nicht mehr nötig ist und welche Alternativen Du nutzen solltest! 🚀

6 min read • 25. März 2026
Mutation Observer: Die unsichtbare Kraft im Hintergrund deiner Webseite 🕵️
Ähnlicher Artikel

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. 2024
Intersection Observer: Ein mächtiges Tool für effizientes Web-Design 🚀
Ähnlicher Artikel

Intersection Observer: Ein mächtiges Tool für effizientes Web-Design 🚀

Entdecke, wie der Intersection Observer deine Webseiten effizienter macht und warum er ein unverzichtbares Tool in deinem Arsenal sein sollte! 🚀

8 min read • 24. Aug. 2024