Back to Docs

Tutorial: Contact Form

Build a contact form that saves submissions directly to Google Sheets.

Tutorial: Build a Contact Form

In this tutorial, you’ll learn how to create a contact form that saves submissions directly to a Google Sheet using SheetsJSON’s Write API. This is perfect for landing pages, feedback forms, or any scenario where you want to collect user input without setting up a backend.

Time to complete: 15-20 minutes

What you’ll build:

  • A responsive contact form with validation
  • Real-time submission to Google Sheets
  • Success/error feedback for users
  • Spam protection with honeypot field

Prerequisites

  • A SheetsJSON account with Pro plan or higher (Write API required)
  • Basic knowledge of HTML and JavaScript
  • A Google Sheet to store submissions

Step 1: Set Up Your Google Sheet

Create a new Google Sheet with the following columns:

timestamp name email subject message source

Leave the rows empty — they’ll be filled by form submissions.

Tip: The timestamp column lets you track when each submission was received. The source column can track which page or campaign the submission came from.

Step 2: Connect Your Sheet to SheetsJSON

  1. Go to your SheetsJSON Dashboard
  2. Click Connect Sheet
  3. Select your contact form spreadsheet
  4. Enable Require API Key for security
  5. Copy your API key and endpoint URL

Step 3: Create the Contact Form

Create an HTML file with a styled contact form:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Contact Us</title>
  <script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-gradient-to-br from-blue-50 to-indigo-100 min-h-screen flex items-center justify-center p-4">
  <div class="w-full max-w-lg">
<!-- Header -->

    <div class="text-center mb-8">
      <h1 class="text-3xl font-bold text-gray-900">Get in Touch</h1>
      <p class="text-gray-600 mt-2">We'd love to hear from you. Send us a message!</p>
    </div>
    
<!-- Contact Form -->

    <form id="contact-form" class="bg-white rounded-2xl shadow-xl p-8">
<!-- Honeypot field (spam protection) -->

      <input type="text" name="website" class="hidden" tabindex="-1" autocomplete="off">
      
<!-- Name -->

      <div class="mb-6">
        <label for="name" class="block text-sm font-medium text-gray-700 mb-2">
          Full Name <span class="text-red-500">*</span>
        </label>
        <input 
          type="text" 
          id="name" 
          name="name" 
          required
          placeholder="John Doe"
          class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
        >
      </div>
      
<!-- Email -->

      <div class="mb-6">
        <label for="email" class="block text-sm font-medium text-gray-700 mb-2">
          Email Address <span class="text-red-500">*</span>
        </label>
        <input 
          type="email" 
          id="email" 
          name="email" 
          required
          placeholder="[email protected]"
          class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
        >
      </div>
      
<!-- Subject -->

      <div class="mb-6">
        <label for="subject" class="block text-sm font-medium text-gray-700 mb-2">
          Subject <span class="text-red-500">*</span>
        </label>
        <select 
          id="subject" 
          name="subject" 
          required
          class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
        >
          <option value="">Select a topic...</option>
          <option value="General Inquiry">General Inquiry</option>
          <option value="Sales">Sales</option>
          <option value="Support">Support</option>
          <option value="Partnership">Partnership</option>
          <option value="Other">Other</option>
        </select>
      </div>
      
<!-- Message -->

      <div class="mb-6">
        <label for="message" class="block text-sm font-medium text-gray-700 mb-2">
          Message <span class="text-red-500">*</span>
        </label>
        <textarea 
          id="message" 
          name="message" 
          required
          rows="5"
          placeholder="Tell us how we can help..."
          class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all resize-none"
        ></textarea>
        <p class="text-sm text-gray-500 mt-1">
          <span id="char-count">0</span>/500 characters
        </p>
      </div>
      
<!-- Submit Button -->

      <button 
        type="submit" 
        id="submit-btn"
        class="w-full bg-blue-600 text-white font-semibold py-3 px-6 rounded-lg hover:bg-blue-700 focus:ring-4 focus:ring-blue-300 transition-all disabled:opacity-50 disabled:cursor-not-allowed"
      >
        <span id="btn-text">Send Message</span>
        <span id="btn-loading" class="hidden">
          <svg class="animate-spin inline-block w-5 h-5 mr-2" fill="none" viewBox="0 0 24 24">
            <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
            <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
          </svg>
          Sending...
        </span>
      </button>
    </form>
    
<!-- Success Message -->

    <div id="success-message" class="hidden bg-white rounded-2xl shadow-xl p-8 text-center">
      <div class="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
        <svg class="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
          <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
        </svg>
      </div>
      <h2 class="text-2xl font-bold text-gray-900 mb-2">Message Sent!</h2>
      <p class="text-gray-600 mb-6">Thank you for reaching out. We'll get back to you within 24 hours.</p>
      <button 
        onclick="resetForm()"
        class="text-blue-600 font-medium hover:underline"
      >
        Send another message
      </button>
    </div>
    
<!-- Error Message -->

    <div id="error-message" class="hidden mt-4 bg-red-50 border border-red-200 rounded-lg p-4">
      <div class="flex items-start">
        <svg class="w-5 h-5 text-red-600 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
          <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
        </svg>
        <div class="ml-3">
          <h3 class="text-sm font-medium text-red-800">Submission Failed</h3>
          <p id="error-text" class="text-sm text-red-700 mt-1">Please try again later.</p>
        </div>
      </div>
    </div>
  </div>

  <script>
    // Configuration - Replace these with your values!
    const CONFIG = {
      apiBase: 'https://api.sheetsjson.com/api/sheets',
      accountSlug: 'your-account',    // Your account slug
      sheetSlug: 'contact-form',      // Your sheet slug
      apiKey: 'your-api-key',         // Your API key
      source: 'website-contact-page'  // Track submission source
    };
    
    // DOM Elements
    const form = document.getElementById('contact-form');
    const submitBtn = document.getElementById('submit-btn');
    const btnText = document.getElementById('btn-text');
    const btnLoading = document.getElementById('btn-loading');
    const successMessage = document.getElementById('success-message');
    const errorMessage = document.getElementById('error-message');
    const errorText = document.getElementById('error-text');
    const charCount = document.getElementById('char-count');
    const messageInput = document.getElementById('message');
    
    // Character counter
    messageInput.addEventListener('input', () => {
      const count = messageInput.value.length;
      charCount.textContent = count;
      charCount.classList.toggle('text-red-500', count > 500);
    });
    
    // Form submission
    form.addEventListener('submit', async (e) => {
      e.preventDefault();
      
      // Check honeypot (spam protection)
      const honeypot = form.querySelector('input[name="website"]');
      if (honeypot && honeypot.value) {
        // Likely a bot - silently "succeed"
        showSuccess();
        return;
      }
      
      // Validate message length
      if (messageInput.value.length > 500) {
        showError('Message must be 500 characters or less.');
        return;
      }
      
      // Show loading state
      setLoading(true);
      hideError();
      
      // Gather form data
      const formData = {
        timestamp: new Date().toISOString(),
        name: form.name.value.trim(),
        email: form.email.value.trim(),
        subject: form.subject.value,
        message: form.message.value.trim(),
        source: CONFIG.source
      };
      
      try {
        const response = await fetch(
          `${CONFIG.apiBase}/${CONFIG.accountSlug}/${CONFIG.sheetSlug}`,
          {
            method: 'POST',
            headers: {
              'Authorization': `Bearer ${CONFIG.apiKey}`,
              'Content-Type': 'application/json'
            },
            body: JSON.stringify(formData)
          }
        );
        
        if (!response.ok) {
          const error = await response.json();
          
          if (response.status === 429) {
            throw new Error('Too many submissions. Please wait a moment and try again.');
          }
          
          throw new Error(error.error || 'Failed to submit form');
        }
        
        // Success!
        showSuccess();
        
      } catch (error) {
        console.error('Form submission error:', error);
        showError(error.message);
      } finally {
        setLoading(false);
      }
    });
    
    // Helper functions
    function setLoading(loading) {
      submitBtn.disabled = loading;
      btnText.classList.toggle('hidden', loading);
      btnLoading.classList.toggle('hidden', !loading);
    }
    
    function showSuccess() {
      form.classList.add('hidden');
      successMessage.classList.remove('hidden');
    }
    
    function showError(message) {
      errorText.textContent = message;
      errorMessage.classList.remove('hidden');
    }
    
    function hideError() {
      errorMessage.classList.add('hidden');
    }
    
    function resetForm() {
      form.reset();
      charCount.textContent = '0';
      form.classList.remove('hidden');
      successMessage.classList.add('hidden');
      hideError();
    }
  </script>
</body>
</html>

Step 4: Add Client-Side Validation

Enhance the form with real-time validation:

// Add validation styles
function validateField(field) {
  const isValid = field.checkValidity();
  
  if (isValid) {
    field.classList.remove('border-red-500');
    field.classList.add('border-green-500');
  } else {
    field.classList.remove('border-green-500');
    field.classList.add('border-red-500');
  }
  
  return isValid;
}

// Email validation regex
function isValidEmail(email) {
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}

// Add blur event listeners for real-time validation
document.querySelectorAll('#contact-form input, #contact-form select, #contact-form textarea').forEach(field => {
  field.addEventListener('blur', () => validateField(field));
});

// Custom email validation
document.getElementById('email').addEventListener('blur', function() {
  if (this.value && !isValidEmail(this.value)) {
    this.setCustomValidity('Please enter a valid email address');
    this.classList.remove('border-green-500');
    this.classList.add('border-red-500');
  } else {
    this.setCustomValidity('');
    validateField(this);
  }
});

Step 5: Add Rate Limiting Protection

Prevent abuse by limiting submissions from the same user:

// Rate limiting (client-side)
const RATE_LIMIT_KEY = 'contact_form_submissions';
const RATE_LIMIT_MAX = 3;
const RATE_LIMIT_WINDOW = 60 * 60 * 1000; // 1 hour

function checkRateLimit() {
  const submissions = JSON.parse(localStorage.getItem(RATE_LIMIT_KEY) || '[]');
  const now = Date.now();
  
  // Filter to only recent submissions
  const recentSubmissions = submissions.filter(time => now - time < RATE_LIMIT_WINDOW);
  
  if (recentSubmissions.length >= RATE_LIMIT_MAX) {
    const oldestTime = Math.min(...recentSubmissions);
    const waitTime = Math.ceil((RATE_LIMIT_WINDOW - (now - oldestTime)) / 60000);
    return { allowed: false, waitMinutes: waitTime };
  }
  
  return { allowed: true };
}

function recordSubmission() {
  const submissions = JSON.parse(localStorage.getItem(RATE_LIMIT_KEY) || '[]');
  submissions.push(Date.now());
  
  // Keep only recent submissions
  const now = Date.now();
  const recentSubmissions = submissions.filter(time => now - time < RATE_LIMIT_WINDOW);
  localStorage.setItem(RATE_LIMIT_KEY, JSON.stringify(recentSubmissions));
}

// Use in form submission
form.addEventListener('submit', async (e) => {
  e.preventDefault();
  
  // Check rate limit first
  const rateLimit = checkRateLimit();
  if (!rateLimit.allowed) {
    showError(`Too many submissions. Please try again in ${rateLimit.waitMinutes} minutes.`);
    return;
  }
  
  // ... rest of submission logic
  
  // On success, record the submission
  recordSubmission();
  showSuccess();
});

Step 6: Email Notifications (Optional)

While SheetsJSON doesn’t send emails directly, you can set up notifications:

Option 1: Google Sheets Email Notifications

  1. In your Google Sheet, go to ToolsNotification rules
  2. Set up email notifications when changes are made

Option 2: Apps Script Automation

Add this script to your Google Sheet (Extensions → Apps Script):

function onFormSubmit(e) {
  var sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
  var lastRow = sheet.getLastRow();
  var data = sheet.getRange(lastRow, 1, 1, 6).getValues()[0];
  
  var timestamp = data[0];
  var name = data[1];
  var email = data[2];
  var subject = data[3];
  var message = data[4];
  
  // Send email notification
  MailApp.sendEmail({
    to: '[email protected]',
    subject: 'New Contact Form Submission: ' + subject,
    body: `
New contact form submission received!

Name: ${name}
Email: ${email}
Subject: ${subject}
Message: ${message}

Submitted at: ${timestamp}
    `
  });
}

// Create trigger: Edit → Current project's triggers → Add trigger
// Choose: onFormSubmit, From spreadsheet, On change

Complete Example with All Features

Here’s the full JavaScript with all features combined:

// Configuration
const CONFIG = {
  apiBase: 'https://api.sheetsjson.com/api/sheets',
  accountSlug: 'your-account',
  sheetSlug: 'contact-form',
  apiKey: 'your-api-key',
  source: 'website-contact-page'
};

// Rate limiting
const RATE_LIMIT_KEY = 'contact_form_submissions';
const RATE_LIMIT_MAX = 3;
const RATE_LIMIT_WINDOW = 60 * 60 * 1000;

// DOM Elements
const form = document.getElementById('contact-form');
const submitBtn = document.getElementById('submit-btn');
const btnText = document.getElementById('btn-text');
const btnLoading = document.getElementById('btn-loading');
const successMessage = document.getElementById('success-message');
const errorMessage = document.getElementById('error-message');
const errorText = document.getElementById('error-text');
const charCount = document.getElementById('char-count');
const messageInput = document.getElementById('message');

// Initialize
messageInput.addEventListener('input', () => {
  const count = messageInput.value.length;
  charCount.textContent = count;
  charCount.classList.toggle('text-red-500', count > 500);
});

// Form submission
form.addEventListener('submit', async (e) => {
  e.preventDefault();
  
  // Honeypot check
  const honeypot = form.querySelector('input[name="website"]');
  if (honeypot && honeypot.value) {
    showSuccess();
    return;
  }
  
  // Rate limit check
  const rateLimit = checkRateLimit();
  if (!rateLimit.allowed) {
    showError(`Too many submissions. Please try again in ${rateLimit.waitMinutes} minutes.`);
    return;
  }
  
  // Message length check
  if (messageInput.value.length > 500) {
    showError('Message must be 500 characters or less.');
    return;
  }
  
  setLoading(true);
  hideError();
  
  const formData = {
    timestamp: new Date().toISOString(),
    name: form.name.value.trim(),
    email: form.email.value.trim(),
    subject: form.subject.value,
    message: form.message.value.trim(),
    source: CONFIG.source
  };
  
  try {
    const response = await fetch(
      `${CONFIG.apiBase}/${CONFIG.accountSlug}/${CONFIG.sheetSlug}`,
      {
        method: 'POST',
        headers: {
          'Authorization': `Bearer ${CONFIG.apiKey}`,
          'Content-Type': 'application/json'
        },
        body: JSON.stringify(formData)
      }
    );
    
    if (!response.ok) {
      const error = await response.json();
      if (response.status === 429) {
        throw new Error('Too many submissions. Please wait a moment.');
      }
      throw new Error(error.error || 'Failed to submit form');
    }
    
    recordSubmission();
    showSuccess();
    
  } catch (error) {
    console.error('Form submission error:', error);
    showError(error.message);
  } finally {
    setLoading(false);
  }
});

// Helper functions
function setLoading(loading) {
  submitBtn.disabled = loading;
  btnText.classList.toggle('hidden', loading);
  btnLoading.classList.toggle('hidden', !loading);
}

function showSuccess() {
  form.classList.add('hidden');
  successMessage.classList.remove('hidden');
}

function showError(message) {
  errorText.textContent = message;
  errorMessage.classList.remove('hidden');
}

function hideError() {
  errorMessage.classList.add('hidden');
}

function resetForm() {
  form.reset();
  charCount.textContent = '0';
  form.classList.remove('hidden');
  successMessage.classList.add('hidden');
  hideError();
}

function checkRateLimit() {
  const submissions = JSON.parse(localStorage.getItem(RATE_LIMIT_KEY) || '[]');
  const now = Date.now();
  const recentSubmissions = submissions.filter(time => now - time < RATE_LIMIT_WINDOW);
  
  if (recentSubmissions.length >= RATE_LIMIT_MAX) {
    const oldestTime = Math.min(...recentSubmissions);
    const waitTime = Math.ceil((RATE_LIMIT_WINDOW - (now - oldestTime)) / 60000);
    return { allowed: false, waitMinutes: waitTime };
  }
  return { allowed: true };
}

function recordSubmission() {
  const submissions = JSON.parse(localStorage.getItem(RATE_LIMIT_KEY) || '[]');
  submissions.push(Date.now());
  const now = Date.now();
  const recentSubmissions = submissions.filter(time => now - time < RATE_LIMIT_WINDOW);
  localStorage.setItem(RATE_LIMIT_KEY, JSON.stringify(recentSubmissions));
}

Security Best Practices

  1. Never expose your API key in client-side code for production — Use a backend proxy or serverless function
  2. Enable API key authentication on your sheet
  3. Use HTTPS for all requests
  4. Implement honeypot fields to catch bots
  5. Add rate limiting both client and server-side
  6. Validate all input before submission

Next Steps

  • Add CAPTCHA for additional bot protection
  • Set up email notifications with Apps Script
  • Create a dashboard to view submissions
  • Add file upload support (requires additional setup)

Related Tutorials

Was this page helpful? |