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 | subject | message | source | |
|---|---|---|---|---|---|
Leave the rows empty — they’ll be filled by form submissions.
Tip: The
timestampcolumn lets you track when each submission was received. Thesourcecolumn can track which page or campaign the submission came from.
Step 2: Connect Your Sheet to SheetsJSON
- Go to your SheetsJSON Dashboard
- Click Connect Sheet
- Select your contact form spreadsheet
- Enable Require API Key for security
- 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
- In your Google Sheet, go to Tools → Notification rules
- 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
- Never expose your API key in client-side code for production — Use a backend proxy or serverless function
- Enable API key authentication on your sheet
- Use HTTPS for all requests
- Implement honeypot fields to catch bots
- Add rate limiting both client and server-side
- 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
- Build a Product Catalog — Display products from a sheet
- Create a Blog — Power a blog with Google Sheets
- Real-time Dashboard — Build a live data dashboard