Tutorial: Build a Blog Powered by Google Sheets
In this tutorial, you’ll learn how to create a fully functional blog using Google Sheets as your content management system (CMS). This approach gives you the simplicity of a spreadsheet editor with the power of a dynamic website.
Time to complete: 25-30 minutes
What you’ll build:
- A blog homepage with post listings
- Individual post pages with full content
- Category filtering and search
- SEO-friendly metadata
Prerequisites
- A SheetsJSON account (sign up here)
- Basic knowledge of HTML, CSS, and JavaScript
- A Google Sheet for your blog content
Why Use Google Sheets as a CMS?
- ✅ Familiar interface — Anyone can update content
- ✅ Real-time collaboration — Multiple editors can work simultaneously
- ✅ Version history — Built-in revision tracking
- ✅ No database needed — Sheets handles storage
- ✅ Mobile editing — Update posts from anywhere
Step 1: Set Up Your Blog Sheet
Create a Google Sheet with these columns:
| slug | title | excerpt | content | author | author_avatar | category | tags | featured_image | published | published_at |
|---|---|---|---|---|---|---|---|---|---|---|
| hello-world | Hello World | Welcome to my blog! | Full markdown content… | Jane Doe | https://… | Tech | javascript,tutorial | https://… | true | 2024-01-15 |
Column Descriptions
| Column | Type | Description |
|---|---|---|
slug |
String |
URL-friendly identifier (e.g., my-first-post) |
title |
String | Post title |
excerpt |
String | Short summary (150-200 chars) |
content |
String | Full post content (supports Markdown) |
author |
String | Author’s display name |
author_avatar |
URL | Author’s profile image |
category |
String | Primary category |
tags |
String | Comma-separated tags |
featured_image |
URL | Hero image for the post |
published |
Boolean |
true or false |
published_at |
Date | Publication date (YYYY-MM-DD) |
Tip: For long content, you can use multiple cells merged together or link to a Google Doc and fetch content separately.
Step 2: Connect to SheetsJSON
- Go to your SheetsJSON Dashboard
- Click Connect Sheet and select your blog sheet
-
Note your endpoint:
https://api.sheetsjson.com/api/sheets/{account}/{sheet} - Set cache TTL to 60-300 seconds for good performance
Step 3: Create the Blog Structure
Create the following file structure:
blog/
├── index.html # Blog homepage
├── post.html # Individual post page
├── styles.css # Custom styles
└── blog.js # Shared JavaScript
Create the Shared JavaScript (blog.js)
// Blog Configuration
const BLOG_CONFIG = {
apiBase: 'https://api.sheetsjson.com/api/sheets',
accountSlug: 'your-account', // Replace with your account slug
sheetSlug: 'blog-posts', // Replace with your sheet slug
apiKey: 'your-api-key', // Replace if authentication is required
postsPerPage: 10,
siteName: 'My Awesome Blog'
};
// Fetch posts from SheetsJSON
async function fetchPosts(params = {}) {
const url = new URL(`${BLOG_CONFIG.apiBase}/${BLOG_CONFIG.accountSlug}/${BLOG_CONFIG.sheetSlug}`);
// Always filter to published posts only
url.searchParams.set('filter[published]', 'true');
// Add additional parameters
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
url.searchParams.set(key, value);
}
});
const headers = {};
if (BLOG_CONFIG.apiKey) {
headers['Authorization'] = `Bearer ${BLOG_CONFIG.apiKey}`;
}
try {
const response = await fetch(url, { headers });
if (!response.ok) {
throw new Error(`HTTP error: ${response.status}`);
}
const data = await response.json();
return data.data || [];
} catch (error) {
console.error('Failed to fetch posts:', error);
return [];
}
}
// Fetch a single post by slug
async function fetchPostBySlug(slug) {
const posts = await fetchPosts({
'filter[slug]': slug,
'limit': 1
});
return posts[0] || null;
}
// Format date for display
function formatDate(dateString) {
if (!dateString) return '';
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
}
// Parse tags from comma-separated string
function parseTags(tagsString) {
if (!tagsString) return [];
return tagsString.split(',').map(tag => tag.trim()).filter(Boolean);
}
// Simple Markdown to HTML converter
function markdownToHtml(markdown) {
if (!markdown) return '';
return markdown
// Headers
.replace(/^### (.*$)/gim, '<h3>$1</h3>')
.replace(/^## (.*$)/gim, '<h2>$1</h2>')
.replace(/^# (.*$)/gim, '<h1>$1</h1>')
// Bold
.replace(/\*\*(.*?)\*\*/gim, '<strong>$1</strong>')
// Italic
.replace(/\*(.*?)\*/gim, '<em>$1</em>')
// Links
.replace(/\[(.*?)\]\((.*?)\)/gim, '<a href="$2" class="text-blue-600 hover:underline">$1</a>')
// Images
.replace(/!\[(.*?)\]\((.*?)\)/gim, '<img src="$2" alt="$1" class="rounded-lg my-4">')
// Code blocks
.replace(/```([\s\S]*?)```/gim, '<pre class="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto my-4"><code>$1</code></pre>')
// Inline code
.replace(/`(.*?)`/gim, '<code class="bg-gray-100 px-1 py-0.5 rounded text-sm">$1</code>')
// Blockquotes
.replace(/^\> (.*$)/gim, '<blockquote class="border-l-4 border-blue-500 pl-4 italic text-gray-600 my-4">$1</blockquote>')
// Unordered lists
.replace(/^\- (.*$)/gim, '<li>$1</li>')
// Paragraphs (simple version)
.replace(/\n\n/gim, '</p><p class="mb-4">')
// Line breaks
.replace(/\n/gim, '<br>');
}
// Calculate reading time
function calculateReadingTime(content) {
if (!content) return '1 min read';
const wordsPerMinute = 200;
const wordCount = content.split(/\s+/).length;
const minutes = Math.ceil(wordCount / wordsPerMinute);
return `${minutes} min read`;
}
// Update page metadata
function updateMetadata(title, description, image) {
document.title = title ? `${title} | ${BLOG_CONFIG.siteName}` : BLOG_CONFIG.siteName;
// Update meta description
let metaDesc = document.querySelector('meta[name="description"]');
if (!metaDesc) {
metaDesc = document.createElement('meta');
metaDesc.name = 'description';
document.head.appendChild(metaDesc);
}
metaDesc.content = description || '';
// Update Open Graph tags
updateOgTag('og:title', title);
updateOgTag('og:description', description);
updateOgTag('og:image', image);
}
function updateOgTag(property, content) {
let tag = document.querySelector(`meta[property="${property}"]`);
if (!tag) {
tag = document.createElement('meta');
tag.setAttribute('property', property);
document.head.appendChild(tag);
}
tag.content = content || '';
}
// Get URL parameters
function getUrlParam(param) {
const urlParams = new URLSearchParams(window.location.search);
return urlParams.get(param);
}
Step 4: Create the Blog Homepage
Create index.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>My Awesome Blog</title>
<meta name="description" content="Insights on technology, design, and more.">
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-gray-50 min-h-screen">
<!-- Header -->
<header class="bg-white shadow-sm sticky top-0 z-10">
<div class="max-w-4xl mx-auto px-4 py-4">
<div class="flex items-center justify-between">
<a href="index.html" class="text-2xl font-bold text-gray-900">My Blog</a>
<nav class="flex items-center gap-6">
<a href="index.html" class="text-gray-600 hover:text-gray-900">Home</a>
<a href="index.html?category=Tech" class="text-gray-600 hover:text-gray-900">Tech</a>
<a href="index.html?category=Design" class="text-gray-600 hover:text-gray-900">Design</a>
</nav>
</div>
</div>
</header>
<main class="max-w-4xl mx-auto px-4 py-12">
<!-- Search & Filter -->
<div class="mb-8">
<div class="flex flex-col sm:flex-row gap-4">
<input
type="text"
id="search-input"
placeholder="Search posts..."
class="flex-grow px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<select
id="category-filter"
class="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
>
<option value="">All Categories</option>
<option value="Tech">Tech</option>
<option value="Design">Design</option>
<option value="Business">Business</option>
<option value="Lifestyle">Lifestyle</option>
</select>
</div>
</div>
<!-- Category Title -->
<div id="category-title" class="hidden mb-8">
<h1 class="text-3xl font-bold text-gray-900">
Posts in <span id="category-name" class="text-blue-600"></span>
</h1>
</div>
<!-- Posts List -->
<div id="posts-container">
<!-- Loading State -->
<div id="loading" class="text-center py-12">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p class="mt-4 text-gray-600">Loading posts...</p>
</div>
</div>
<!-- Load More Button -->
<div id="load-more-container" class="hidden text-center mt-8">
<button
id="load-more-btn"
class="px-6 py-3 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition-colors"
>
Load More Posts
</button>
</div>
</main>
<!-- Footer -->
<footer class="bg-white border-t mt-16">
<div class="max-w-4xl mx-auto px-4 py-8 text-center text-gray-600">
<p>© 2024 My Blog. Powered by <a href="https://sheetsjson.com" class="text-blue-600 hover:underline">SheetsJSON</a></p>
</div>
</footer>
<script src="blog.js"></script>
<script>
// State
let currentOffset = 0;
let currentCategory = '';
let currentSearch = '';
let hasMore = true;
// DOM Elements
const postsContainer = document.getElementById('posts-container');
const loadingEl = document.getElementById('loading');
const loadMoreContainer = document.getElementById('load-more-container');
const loadMoreBtn = document.getElementById('load-more-btn');
const categoryTitle = document.getElementById('category-title');
const categoryName = document.getElementById('category-name');
const searchInput = document.getElementById('search-input');
const categoryFilter = document.getElementById('category-filter');
// Render a post card
function renderPostCard(post) {
const tags = parseTags(post.tags);
const readingTime = calculateReadingTime(post.content);
return `
<article class="bg-white rounded-xl shadow-sm overflow-hidden hover:shadow-md transition-shadow mb-6">
<a href="post.html?slug=${post.slug}" class="block">
${post.featured_image ? `
<img
src="${post.featured_image}"
alt="${post.title}"
class="w-full h-48 object-cover"
onerror="this.style.display='none'"
>
` : ''}
<div class="p-6">
<div class="flex items-center gap-2 mb-3">
<span class="text-sm font-medium text-blue-600">${post.category}</span>
<span class="text-gray-300">•</span>
<span class="text-sm text-gray-500">${formatDate(post.published_at)}</span>
<span class="text-gray-300">•</span>
<span class="text-sm text-gray-500">${readingTime}</span>
</div>
<h2 class="text-xl font-bold text-gray-900 mb-2 hover:text-blue-600 transition-colors">
${post.title}
</h2>
<p class="text-gray-600 mb-4 line-clamp-2">${post.excerpt}</p>
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
${post.author_avatar ? `
<img
src="${post.author_avatar}"
alt="${post.author}"
class="w-8 h-8 rounded-full object-cover"
>
` : `
<div class="w-8 h-8 rounded-full bg-blue-100 flex items-center justify-center">
<span class="text-blue-600 font-medium">${post.author?.charAt(0) || 'A'}</span>
</div>
`}
<span class="text-sm text-gray-700">${post.author || 'Anonymous'}</span>
</div>
<div class="flex gap-2">
${tags.slice(0, 2).map(tag => `
<span class="text-xs bg-gray-100 text-gray-600 px-2 py-1 rounded">${tag}</span>
`).join('')}
</div>
</div>
</div>
</a>
</article>
`;
}
// Load posts
async function loadPosts(reset = false) {
if (reset) {
currentOffset = 0;
postsContainer.innerHTML = '';
loadingEl.classList.remove('hidden');
postsContainer.appendChild(loadingEl);
}
const params = {
'sort': '-published_at',
'limit': BLOG_CONFIG.postsPerPage,
'offset': currentOffset
};
if (currentCategory) {
params['filter[category]'] = currentCategory;
}
if (currentSearch) {
params['filter[title][contains]'] = currentSearch;
}
const posts = await fetchPosts(params);
loadingEl.classList.add('hidden');
if (posts.length === 0 && currentOffset === 0) {
postsContainer.innerHTML = `
<div class="text-center py-12">
<p class="text-gray-600">No posts found.</p>
</div>
`;
loadMoreContainer.classList.add('hidden');
return;
}
posts.forEach(post => {
postsContainer.insertAdjacentHTML('beforeend', renderPostCard(post));
});
currentOffset += posts.length;
hasMore = posts.length === BLOG_CONFIG.postsPerPage;
loadMoreContainer.classList.toggle('hidden', !hasMore);
}
// Filter handlers
function handleSearch() {
currentSearch = searchInput.value.trim();
loadPosts(true);
}
function handleCategoryChange() {
currentCategory = categoryFilter.value;
if (currentCategory) {
categoryTitle.classList.remove('hidden');
categoryName.textContent = currentCategory;
} else {
categoryTitle.classList.add('hidden');
}
loadPosts(true);
}
// Debounce for search
function debounce(func, wait) {
let timeout;
return function(...args) {
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(this, args), wait);
};
}
// Initialize
function init() {
// Check URL params
const urlCategory = getUrlParam('category');
if (urlCategory) {
currentCategory = urlCategory;
categoryFilter.value = urlCategory;
categoryTitle.classList.remove('hidden');
categoryName.textContent = urlCategory;
}
// Event listeners
searchInput.addEventListener('input', debounce(handleSearch, 300));
categoryFilter.addEventListener('change', handleCategoryChange);
loadMoreBtn.addEventListener('click', () => loadPosts(false));
// Initial load
loadPosts(true);
}
init();
</script>
</body>
</html>
Step 5: Create the Post Page
Create post.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Loading... | My Blog</title>
<meta name="description" content="">
<meta property="og:title" content="">
<meta property="og:description" content="">
<meta property="og:image" content="">
<script src="https://cdn.tailwindcss.com"></script>
<style>
.prose h1 { font-size: 2rem; font-weight: 700; margin-bottom: 1rem; }
.prose h2 { font-size: 1.5rem; font-weight: 600; margin-top: 2rem; margin-bottom: 0.75rem; }
.prose h3 { font-size: 1.25rem; font-weight: 600; margin-top: 1.5rem; margin-bottom: 0.5rem; }
.prose p { margin-bottom: 1rem; line-height: 1.75; }
.prose ul, .prose ol { margin-bottom: 1rem; padding-left: 1.5rem; }
.prose li { margin-bottom: 0.5rem; }
.prose img { max-width: 100%; height: auto; }
</style>
</head>
<body class="bg-gray-50 min-h-screen">
<!-- Header -->
<header class="bg-white shadow-sm">
<div class="max-w-4xl mx-auto px-4 py-4">
<div class="flex items-center justify-between">
<a href="index.html" class="text-2xl font-bold text-gray-900">My Blog</a>
<a href="index.html" class="text-gray-600 hover:text-gray-900">← Back to Blog</a>
</div>
</div>
</header>
<main class="max-w-3xl mx-auto px-4 py-12">
<!-- Loading State -->
<div id="loading" class="text-center py-12">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto"></div>
<p class="mt-4 text-gray-600">Loading post...</p>
</div>
<!-- Post Content -->
<article id="post-content" class="hidden">
<!-- Featured Image -->
<img id="post-image" src="" alt="" class="w-full h-64 md:h-96 object-cover rounded-xl mb-8">
<!-- Post Header -->
<header class="mb-8">
<div class="flex items-center gap-2 mb-4">
<a id="post-category" href="#" class="text-sm font-medium text-blue-600 hover:underline"></a>
<span class="text-gray-300">•</span>
<span id="post-date" class="text-sm text-gray-500"></span>
<span class="text-gray-300">•</span>
<span id="post-reading-time" class="text-sm text-gray-500"></span>
</div>
<h1 id="post-title" class="text-3xl md:text-4xl font-bold text-gray-900 mb-4"></h1>
<p id="post-excerpt" class="text-xl text-gray-600"></p>
</header>
<!-- Author -->
<div class="flex items-center gap-4 p-4 bg-white rounded-xl mb-8">
<img id="author-avatar" src="" alt="" class="w-12 h-12 rounded-full object-cover">
<div>
<p id="author-name" class="font-medium text-gray-900"></p>
<p class="text-sm text-gray-500">Author</p>
</div>
</div>
<!-- Content -->
<div id="post-body" class="prose max-w-none bg-white rounded-xl p-8 shadow-sm">
<!-- Content will be inserted here -->
</div>
<!-- Tags -->
<div id="post-tags" class="mt-8 flex flex-wrap gap-2">
<!-- Tags will be inserted here -->
</div>
<!-- Share -->
<div class="mt-8 p-6 bg-white rounded-xl">
<p class="text-sm text-gray-600 mb-3">Share this post:</p>
<div class="flex gap-3">
<a id="share-twitter" href="#" target="_blank" class="px-4 py-2 bg-blue-400 text-white rounded-lg hover:bg-blue-500 transition-colors">
Twitter
</a>
<a id="share-linkedin" href="#" target="_blank" class="px-4 py-2 bg-blue-700 text-white rounded-lg hover:bg-blue-800 transition-colors">
LinkedIn
</a>
<button id="share-copy" class="px-4 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors">
Copy Link
</button>
</div>
</div>
</article>
<!-- Not Found -->
<div id="not-found" class="hidden text-center py-12">
<h1 class="text-2xl font-bold text-gray-900 mb-4">Post Not Found</h1>
<p class="text-gray-600 mb-6">The post you're looking for doesn't exist or has been removed.</p>
<a href="index.html" class="text-blue-600 hover:underline">← Back to Blog</a>
</div>
</main>
<!-- Footer -->
<footer class="bg-white border-t mt-16">
<div class="max-w-4xl mx-auto px-4 py-8 text-center text-gray-600">
<p>© 2024 My Blog. Powered by <a href="https://sheetsjson.com" class="text-blue-600 hover:underline">SheetsJSON</a></p>
</div>
</footer>
<script src="blog.js"></script>
<script>
// DOM Elements
const loadingEl = document.getElementById('loading');
const postContent = document.getElementById('post-content');
const notFound = document.getElementById('not-found');
// Render post
function renderPost(post) {
// Update metadata
updateMetadata(post.title, post.excerpt, post.featured_image);
// Featured image
const postImage = document.getElementById('post-image');
if (post.featured_image) {
postImage.src = post.featured_image;
postImage.alt = post.title;
} else {
postImage.style.display = 'none';
}
// Header
document.getElementById('post-category').textContent = post.category;
document.getElementById('post-category').href = `index.html?category=${encodeURIComponent(post.category)}`;
document.getElementById('post-date').textContent = formatDate(post.published_at);
document.getElementById('post-reading-time').textContent = calculateReadingTime(post.content);
document.getElementById('post-title').textContent = post.title;
document.getElementById('post-excerpt').textContent = post.excerpt;
// Author
const authorAvatar = document.getElementById('author-avatar');
if (post.author_avatar) {
authorAvatar.src = post.author_avatar;
authorAvatar.alt = post.author;
} else {
authorAvatar.style.display = 'none';
}
document.getElementById('author-name').textContent = post.author || 'Anonymous';
// Content
document.getElementById('post-body').innerHTML = `<p class="mb-4">${markdownToHtml(post.content)}</p>`;
// Tags
const tags = parseTags(post.tags);
const tagsContainer = document.getElementById('post-tags');
tagsContainer.innerHTML = tags.map(tag => `
<span class="px-3 py-1 bg-gray-100 text-gray-600 rounded-full text-sm">#${tag}</span>
`).join('');
// Share links
const pageUrl = encodeURIComponent(window.location.href);
const pageTitle = encodeURIComponent(post.title);
document.getElementById('share-twitter').href =
`https://twitter.com/intent/tweet?url=${pageUrl}&text=${pageTitle}`;
document.getElementById('share-linkedin').href =
`https://www.linkedin.com/sharing/share-offsite/?url=${pageUrl}`;
document.getElementById('share-copy').addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(window.location.href);
alert('Link copied to clipboard!');
} catch (err) {
console.error('Failed to copy:', err);
}
});
// Show content
loadingEl.classList.add('hidden');
postContent.classList.remove('hidden');
}
// Initialize
async function init() {
const slug = getUrlParam('slug');
if (!slug) {
loadingEl.classList.add('hidden');
notFound.classList.remove('hidden');
return;
}
const post = await fetchPostBySlug(slug);
if (!post) {
loadingEl.classList.add('hidden');
notFound.classList.remove('hidden');
return;
}
renderPost(post);
}
init();
</script>
</body>
</html>
Step 6: Writing Content in Google Sheets
Tips for Writing Posts
-
Use Markdown in the content column:
## Introduction This is my first paragraph with **bold** and *italic* text. ### Key Points - Point one - Point two - Point three Here's a [link](https://example.com) to more info. -
For long content, consider using Google Docs and embedding a link, or split content across multiple columns.
-
Use consistent slugs — lowercase, hyphen-separated (e.g.,
my-awesome-post). -
Set
publishedtofalsewhile drafting, then change totruewhen ready.
Step 7: Add Draft Preview (Optional)
Allow authors to preview unpublished posts with a secret parameter:
// In blog.js, modify fetchPosts:
async function fetchPosts(params = {}, includeDrafts = false) {
const url = new URL(`${BLOG_CONFIG.apiBase}/${BLOG_CONFIG.accountSlug}/${BLOG_CONFIG.sheetSlug}`);
// Only filter published if not including drafts
if (!includeDrafts) {
url.searchParams.set('filter[published]', 'true');
}
// ... rest of function
}
// In post.html, check for preview param:
async function init() {
const slug = getUrlParam('slug');
const preview = getUrlParam('preview') === 'true';
// ... fetch post with includeDrafts = preview
}
Access drafts with: post.html?slug=my-draft&preview=true
Deployment Options
- Static Hosting — Deploy to Netlify, Vercel, or GitHub Pages
- Your Own Server — Any web server that can serve static files
- CDN — Use Cloudflare Pages or similar for global distribution
Performance Tips
- Set appropriate cache TTL — 60-300 seconds balances freshness and speed
-
Lazy load images — Add
loading="lazy"to image tags - Paginate posts — Don’t load all posts at once
- Use a CDN for images — Host images on a CDN for faster loading
SEO Considerations
- Unique meta descriptions — Set from post excerpts
- Open Graph tags — For social media sharing
- Clean URLs — Use descriptive slugs
- Structured data — Add JSON-LD for rich search results
- Sitemap — Generate from your posts list
Related Tutorials
- Build a Product Catalog — Display products from a sheet
- Build a Contact Form — Capture form submissions
- Real-time Dashboard — Build a live data dashboard