Building a Retro Guestbook with Astro & Google Sheets (No Database Required!)
How I built a nostalgic 90s-style guestbook using Astro, vanilla JavaScript, and Google Sheets as a free backend. Complete with spam moderation and zero hosting costs.
Remember the good old days of the internet? When every personal website had a guestbook where visitors would leave messages like “Cool site! Check out mine!” or “First!!!1!”?
Well, I decided to bring that nostalgia back—but with a modern twist. No PHP, no MySQL, no server costs. Just Astro, vanilla JavaScript, and… Google Sheets?
Yep. Let me show you how I built a fully functional guestbook that costs exactly $0/month to run.
The final result: A clean, modern guestbook that would make 2003 proud
The Architecture (Or: How to Overthink a Simple Feature)
Here’s the beautiful chaos of this setup:
┌─────────────────┐ ┌──────────────────────┐ ┌─────────────────┐
│ Astro Page │────▶│ Google Apps Script │────▶│ Google Sheets │
│ (Frontend) │◀────│ (Serverless API) │◀────│ (Database) │
└─────────────────┘ └──────────────────────┘ └─────────────────┘
│
▼
localStorage
(Fallback/Cache)
Why Google Sheets?
- Free forever (until Google decides otherwise, but let’s not think about that)
- Built-in UI for moderating messages
- No database setup, migrations, or maintenance
- You can literally open a spreadsheet and approve/delete messages
When your database has conditional formatting and pivot table support
Part 1: The Frontend (Astro + Vanilla JS)
The guestbook lives in a single Astro file. Here’s the skeleton:
---
import Layout from '../layouts/Layout.astro';
const GOOGLE_SCRIPT_URL = 'https://script.google.com/macros/s/YOUR_SCRIPT_ID/exec'
---
<Layout title="Guestbook" description="Sign my guestbook!">
<!-- The magic happens here -->
</Layout>
The Form
Nothing fancy—just good old HTML that actually works:
<form id="guestbook-form" class="space-y-4">
<input type="text" id="name" required maxlength="50"
placeholder="Your name" />
<input type="url" id="website" maxlength="100"
placeholder="Website (optional)" />
<textarea id="message" required maxlength="280" rows="3"
placeholder="Write something..."></textarea>
<button type="submit">Post message</button>
</form>
Notice the maxlength="280"? That’s intentional. If Twitter taught us anything, it’s that character limits make people more creative (or at least more concise).
Clean, minimal, and impossible to mess up
The Character Counter
Because watching numbers count down is oddly satisfying:
const messageInput = document.getElementById('message');
const charCount = document.getElementById('char-count');
messageInput.addEventListener('input', () => {
charCount.textContent = `${messageInput.value.length}/280`;
});
Dynamic Avatar Colors
Instead of boring gray circles, each user gets a unique avatar color based on their name:
function getAvatarColor(name) {
const colors = [
'from-rose-400 to-pink-500',
'from-violet-400 to-purple-500',
'from-blue-400 to-indigo-500',
'from-cyan-400 to-teal-500',
'from-emerald-400 to-green-500',
'from-amber-400 to-orange-500',
'from-slate-400 to-slate-500'
];
// Generate consistent hash from name
let hash = 0;
for (let i = 0; i < name.length; i++) {
hash = name.charCodeAt(i) + ((hash << 5) - hash);
}
return colors[Math.abs(hash) % colors.length];
}
This means “Alice” will always get the same color gradient, creating visual consistency without storing any extra data.
Every visitor gets their own signature gradient
Relative Time Formatting
Nobody wants to see “2025-01-20T14:32:00.000Z”. They want “5m ago”:
function formatTime(timestamp) {
const date = new Date(timestamp);
const diff = Date.now() - date.getTime();
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (minutes < 1) return 'just now';
if (minutes < 60) return `${minutes}m ago`;
if (hours < 24) return `${hours}h ago`;
if (days < 7) return `${days}d ago`;
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
}
Part 2: The “Backend” (Google Apps Script)
This is where it gets spicy. Google Apps Script is basically free serverless functions that can talk to Google Sheets. You write JavaScript, deploy it as a web app, and boom—you have an API.
Setting Up The Sheet
First, create a Google Sheet with these columns:
| A | B | C | D | E |
|---|---|---|---|---|
| Timestamp | Name | Website | Message | Draft |
The Draft column is crucial—it’s our spam filter. New entries come in as TRUE (draft), and only FALSE entries show up on the site.
Your database admin panel is literally a spreadsheet
The doPost Function (Receiving Messages)
This handles incoming form submissions:
function doPost(e) {
// Get the active spreadsheet
const sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
// Parse the incoming JSON data
const data = JSON.parse(e.postData.contents);
// Append a new row with draft = TRUE (needs approval)
sheet.appendRow([
new Date(), // Timestamp
data.name, // Name
data.website || '', // Website (optional)
data.message, // Message
true // Draft status - TRUE means "needs review"
]);
// Return success response
return ContentService.createTextOutput(JSON.stringify({success: true}))
.setMimeType(ContentService.MimeType.JSON);
}
What’s happening here:
e.postData.contentscontains the raw JSON from the frontendJSON.parse()converts it to a usable objectsheet.appendRow()adds a new row to the spreadsheet- The
trueat the end marks it as a draft (pending approval)
From browser to spreadsheet in ~500ms
The doGet Function (Fetching Messages)
This returns approved messages to display on the site:
function doGet() {
const sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
// Get all data from the sheet
const data = sheet.getDataRange().getValues();
// Filter and transform the data
const rows = data.slice(1) // Skip header row
.filter(row => {
// Only show approved messages (draft = FALSE or empty)
return row[4] === false || row[4] === '' || row[4] === 'FALSE';
})
.map(row => ({
timestamp: row[0],
name: row[1],
website: row[2],
message: row[3]
}));
// Return as JSON
return ContentService.createTextOutput(JSON.stringify(rows))
.setMimeType(ContentService.MimeType.JSON);
}
The filtering logic:
row[4] === false- Boolean FALSE in the sheetrow[4] === ''- Empty cell (legacy entries)row[4] === 'FALSE'- String “FALSE” (because Google Sheets is fun like that)
Only rows matching these conditions are returned. Everything else stays hidden.
Approve messages by changing TRUE to FALSE. That’s it. That’s the admin panel.
Part 3: Deploying the Apps Script
Here’s how to get your script online:
Step 1: Create the Script
- Go to script.google.com
- Create a new project
- Paste the
doPostanddoGetfunctions - Save the project
Step 2: Connect to Your Sheet
- Open your Google Sheet
- Go to Extensions > Apps Script
- This links the script to that specific sheet
Step 3: Deploy as Web App
- Click Deploy > New deployment
- Select Web app
- Set Execute as: “Me”
- Set Who has access: “Anyone”
- Click Deploy
- Copy the URL (this is your API endpoint!)
The settings that make the magic work
Step 4: Handle CORS (The Annoying Part)
Google Apps Script doesn’t handle CORS well with POST requests. The solution? Use mode: 'no-cors':
await fetch(GOOGLE_SCRIPT_URL, {
method: 'POST',
mode: 'no-cors', // This is the secret sauce
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(entry)
});
The tradeoff: You won’t get a response body. But you can assume success if no error is thrown (it’s fine, trust me).
Part 4: The Fallback System
What if Google is down? What if the script URL isn’t configured? The guestbook still works thanks to localStorage:
const STORAGE_KEY = 'guestbook_messages';
function loadLocalMessages() {
try {
const saved = localStorage.getItem(STORAGE_KEY);
return saved ? JSON.parse(saved) : getDefaultMessages();
} catch {
return getDefaultMessages();
}
}
function saveLocalMessage(entry) {
const messages = loadLocalMessages();
messages.push(entry);
localStorage.setItem(STORAGE_KEY, JSON.stringify(messages));
}
This creates a graceful degradation:
- Google Sheet works → Messages saved to cloud + localStorage
- Google Sheet fails → Messages saved to localStorage only
- Fresh visitor → Shows default welcome message
When your database is down but the vibes are still up
Part 5: Security Considerations
XSS Prevention
Never trust user input. Always escape HTML:
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Usage
<p>${escapeHtml(entry.message)}</p>
This converts <script>alert('hacked')</script> into harmless text.
Input Validation
Both frontend and “backend” validate inputs:
- Frontend:
maxlengthattributes,requiredfields - Backend: Google Sheets naturally limits cell sizes
Rate Limiting?
Honestly? No. This is a personal site. If someone wants to spam my guestbook, they’re only wasting their own time. Plus, the moderation system means spam never goes public anyway.
My spam prevention strategy: just don’t approve spam
The Complete Message Lifecycle
Let’s trace a message from submission to display:
1. User fills out form
↓
2. JavaScript validates input
↓
3. POST request to Google Apps Script
↓
4. doPost() adds row with draft=TRUE
↓
5. Message appears in Google Sheet (invisible on site)
↓
6. I manually change TRUE to FALSE (approving it)
↓
7. doGet() now includes this message
↓
8. Frontend fetches and displays it
↓
9. Visitor sees the message with fancy avatar gradient
The journey of a guestbook message
Why This Approach?
Pros
- Free: Google Sheets and Apps Script cost nothing
- Simple: No server management, no database migrations
- Moderation: Built-in via the Draft column
- Portable: Easy to export data as CSV anytime
- Offline-capable: localStorage fallback works without internet
Cons
- Google dependency: If Google kills Apps Script, I’m toast
- Latency: ~500ms for the round trip to Google
- CORS headaches:
no-corsmode has limitations - Scaling: Won’t handle viral traffic (but my blog won’t go viral, so…)
Try It Yourself!
Want to add a guestbook to your site? Here’s the TL;DR:
- Create a Google Sheet with columns: Timestamp, Name, Website, Message, Draft
- Add the
doPostanddoGetfunctions to Apps Script - Deploy as a web app (anyone can access)
- Build your frontend form
- Fetch and display approved messages
- Moderate by toggling the Draft column
The nostalgia is real
Wrapping Up
Building this guestbook was a fun exercise in creative problem-solving. Could I have used a “proper” backend? Sure. Firebase, Supabase, PlanetScale—there are countless options. But sometimes the best solution is the one that:
- Costs nothing
- Requires no maintenance
- Solves the problem
- Makes you smile
A Google Sheet as a database definitely makes me smile.
Now go sign my guestbook. I promise I’ll approve your message (unless you’re a bot).
Links:
Got questions? Drop a message in the guestbook (obviously).
Related Posts
Mastering Astro Content Collections: A Complete Guide
Learn how to use Astro's Content Collections to build type-safe, organized content for your blog or portfolio site.
How I Built an AI Chatbot for My Portfolio With Cloudflare Workers for Free
A complete breakdown of building a self-hosted AI chatbot using Cloudflare Workers AI (free), KV rate limiting, D1 database, and Astro. No API keys. No per-request fees. Full source code walkthrough.
Building a Fast Pixel Art Editor with Astro & JS
Learn how to build a high-performance, responsive pixel art editor using the HTML5 Canvas API and Astro. No React, no overhead—just clean, vanilla JavaScript.