Using Google Sheets as a CMS: the honest developer's guide
Google Sheets as a CMS keeps coming up in developer communities for good reason - it's free, non-technical editors already know it, and with the right API layer it actually works.
The question appears in r/webdev discussions on low-cost CMS options with reliable regularity: "Can I just use Google Sheets as a CMS?" The answers split predictably - half the thread says it's a hack that will embarrass you, the other half says they've been running it in production for two years and it's fine.
Both camps are right, for different projects. This guide is for figuring out which camp your project belongs to.
Why the idea keeps coming up
Google Sheets has properties that most CMSes charge for or handle poorly:
- Non-technical editors already know it. The single biggest CMS migration cost is training. Sheets requires zero onboarding for anyone who has used a spreadsheet.
- Version history is built in. Every edit is logged. Rolling back a bad change is a few clicks, faster than most CMS UIs.
- Real collaboration. Multiple people editing simultaneously, cell-level comments, named ranges - this is better than the "one editor at a time" model most CMSes ship with.
- Zero cost. Free on a personal Google account, included in Workspace. No monthly seat fees for editors.
- Instant schema changes. Adding a field is adding a column. No migration scripts, no schema registry, no deployment.
These are real advantages, not rationalizations. The reason r/jamstack frequently features Sheet-backed content setups is that for the right project, it genuinely is the best choice.
What it's actually good for
The honest answer: structured content with low formatting complexity, managed by a small team, at modest volume.
Good fits:
- Blog posts at small volume (under a few hundred). Title, slug, date, body as Markdown, status.
- Product copy and feature descriptions. Editable by a marketing team without a developer.
- FAQ content. Question and answer columns, a category column for grouping.
- Changelog entries. Version, date, description - the changelog CMS use case is one of the cleanest applications.
- Team bios. Name, role, bio, photo URL, LinkedIn.
- Pricing tables. Plan name, price, feature list as a comma-separated string you split in code.
- Feature flags. Feature name, enabled (true/false), percentage rollout - a simple but genuinely useful pattern.
The common thread: these are all cases where content fits naturally in rows and columns, the editing team is small, and the content doesn't need embedded images or complex formatting.
How the structure works
The convention is simple enough that you can explain it in a sentence: the first row is field names, every row below it is one content item.
A Posts tab might look like this:
| slug | title | date | body | status |
|---|---|---|---|---|
| hello-world | Hello World | 2026-06-01 | First post. | published |
| draft-post | Coming Soon | 2026-06-15 | Work in progress. | draft |
Column names become object keys in the API response. That's the entire data model. There's no schema to define, no type system to configure.
You can use multiple tabs in one Sheet - one tab per content type. A Sheet with Posts, Authors, and Tags tabs becomes three distinct endpoints.
The missing piece: a proper HTTP endpoint
Google Sheets has an API, but it's not designed for frontend consumption. The v4 Sheets API requires OAuth, returns data in a cell-matrix format rather than JSON objects, and exposes your Google credentials to the client if you use it directly. It's built for automation scripts, not content delivery.
What you need is a thin layer that:
- Authenticates with Google on the server side
- Translates the sheet data into clean JSON objects
- Exposes a public, CORS-enabled REST endpoint
- Handles filtering, sorting, and pagination
SheetsAPI does exactly this. It runs on Cloudflare Workers (so it's fast globally), is CORS-enabled by default, supports full CRUD, and is free during beta. The source is MIT-licensed if you want to self-host.
Connect your Sheet, get a userKey, and your content is immediately available as a standard REST API.
Fetching content: a complete example
Assume your Sheet has a Posts tab with the columns above. Here's how you'd fetch and render it in plain JavaScript:
const USER_KEY = "your_user_key";
const BASE = "https://sheetsapi.gkit.mreshank.com/api/spreadsheets";
async function getPosts() {
const res = await fetch(`${BASE}/${USER_KEY}/Posts`);
if (!res.ok) throw new Error(`Fetch failed: ${res.status}`);
const data = await res.json();
return data.data; // array of row objects
}
async function render() {
const posts = await getPosts();
const list = document.getElementById("posts");
posts.forEach((post) => {
const li = document.createElement("li");
li.innerHTML = `<a href="/blog/${post.slug}">${post.title}</a>`;
list.appendChild(li);
});
}
render();In React:
type Post = {
slug: string;
title: string;
date: string;
body: string;
status: string;
};
async function fetchPosts(): Promise<Post[]> {
const res = await fetch(
`https://sheetsapi.gkit.mreshank.com/api/spreadsheets/${process.env.NEXT_PUBLIC_SHEETS_USER_KEY}/Posts`,
);
if (!res.ok) throw new Error("Failed to fetch posts");
const json = await res.json();
return json.data as Post[];
}
export default async function BlogPage() {
const posts = await fetchPosts();
return (
<ul>
{posts.map((post) => (
<li key={post.slug}>
<a href={`/blog/${post.slug}`}>{post.title}</a>
</li>
))}
</ul>
);
}Filtering by a status column
You almost certainly want a status column so editors can work on drafts without publishing them. SheetsAPI supports server-side filtering via the search parameter, so you don't have to fetch everything and filter client-side:
const res = await fetch(`${BASE}/${USER_KEY}/Posts?search=status:published`);Only rows where status equals published come back. This matters at scale - you're not shipping unpublished content to the browser.
You can combine filters and add sorting:
// Published posts in a specific category, newest first
const res = await fetch(
`${BASE}/${USER_KEY}/Posts?search=status:published,category:engineering&sortBy=date&order=desc`,
);See the full query reference in the docs for pagination and multi-column sorting.
Honest comparison: Sheets+SheetsAPI vs the alternatives
| Sheets + SheetsAPI | Contentful | Sanity | Notion API | Airtable | |
|---|---|---|---|---|---|
| Cost | Free (beta) | Free tier limited; paid from $300/mo | Free tier generous; paid from $99/mo | Free tier; paid from $10/mo | Free tier; paid from $20/mo |
| Non-tech editor | Excellent - already knows Sheets | Good - purpose-built CMS UI | Learning curve | Good - familiar Notion UI | Good - spreadsheet-like |
| Setup time | ~10 minutes | 30–60 minutes | 1–2 hours | 30 minutes | 20–30 minutes |
| Rich text / WYSIWYG | No (plain text or Markdown only) | Yes | Yes (Portable Text) | Yes (Notion blocks) | No |
| Image hosting | No (store URLs only) | Yes (built-in CDN) | Yes (Sanity CDN) | No (external URLs) | No |
| Scale ceiling | ~10M cells; slow before that | High (API-rate limited) | High | Moderate | Moderate |
| Open source / self-host | MIT licensed | No | No | No | No |
The table makes the trade-offs visible. Sheets wins on cost, editor familiarity, and zero setup. It loses on rich text, image hosting, and scale. If your content needs a WYSIWYG editor or inline image uploads, stop here - use Sanity or Contentful.
The real limitations
These are not edge cases. Know them before you commit.
No rich text or WYSIWYG. Cells hold strings. If you store Markdown, you parse it yourself. If an editor pastes in a Word document, they get a wall of text. There is no formatting toolbar.
No image hosting. You can store a URL like https://cdn.example.com/photo.jpg in a cell, but the Sheet doesn't host images. You need a separate CDN or storage solution (Cloudflare R2, S3, Cloudinary - whatever you already use).
No real draft workflow. A status column is a convention, not a feature. There's no preview mode that shows a draft at a private URL before publishing. There's no approval flow. A determined editor can set status to published at any time.
The 10M cell limit is real, but it's not the first bottleneck. A Sheet with 10,000 rows and 20 columns uses 200,000 cells - well within the limit. But Sheets starts to feel slow around 50,000–100,000 rows, and the API response time increases. For a blog or small product site, you'll never hit this. For a product catalog with tens of thousands of SKUs, use a database.
No relationships. You can store an author ID in a post row and look it up in an Authors tab, but the Sheet doesn't enforce referential integrity. A deleted author row leaves orphaned post rows with no warning.
Structuring your Sheet for a clean migration
If you think you might eventually move to Contentful, Sanity, or a database, structure the Sheet to make that migration straightforward. This costs nothing now and saves hours later.
Use consistent, lowercase, hyphenated column names: slug, title, published-date, author-id, body, status. These map cleanly to most CMS field names and to database column conventions.
Always include a slug column. A slug is the stable identifier for a content item - it's what URLs are built from and what foreign-key relationships reference. Without one, every migration tool has to generate slugs from titles, which produces inconsistencies.
Always include a date column. published-date in ISO 8601 format (2026-06-30) sorts lexicographically and parses unambiguously in every language.
Keep body as Markdown rather than plain prose if your content has any formatting. Every serious CMS has a Markdown import path. Most don't have an import path for unstructured plain text.
Don't use merged cells, color coding, or formulas as data. Merged cells break every API client. Color coding isn't queryable. Formulas create values that look like data but aren't - they break when the source cells move.
If you follow these conventions from the start, migrating to a real CMS is a CSV export and a few hours of import scripting, not a content archaeology project.
When to move to a real CMS (and when not to)
Move when any of these become true: your editors want a formatting toolbar, you need per-item image uploads, you need a proper draft preview URL, or your Sheet has more than 50,000 rows and queries are slowing down.
Don't move just because the architecture feels informal. "It's a spreadsheet" is not a reason to pay $300 a month for Contentful. If your content team can manage the Sheet without you, your editors are shipping content on their own schedule, and your page loads are fast - that's a working system. Leave it alone.
For most small blogs, marketing sites, and internal tools, a Google Sheet with SheetsAPI as the delivery layer is more than enough. It's free during beta, open source, and runs on Cloudflare's edge network globally.
If you're starting a new project or already have a Sheet full of content, get started in the dashboard - setup takes about ten minutes. The headless CMS use case has a more detailed walkthrough, and the changelog CMS use case shows a specific pattern that works particularly well. Check pricing before beta ends if you want to lock in the free tier, and see what else is available across GKit's 50+ free dev tools.