Using Google Sheets as a Headless CMS
How to use Google Sheets as a simple headless CMS for blogs, landing pages, and marketing sites — with SheetsAPI as the content delivery layer.
What "headless CMS" actually means
A traditional CMS — WordPress, Squarespace — bundles content storage with the frontend that renders it. A headless CMS separates those two things: content lives in one place, and your frontend fetches it over an API and decides how to render it. Contentful, Sanity, and Notion are headless CMSes. So is a Google Sheet, if you're willing to treat it that way.
For the right project, a Sheet is genuinely better than a dedicated CMS. Your content team already knows how to use it. You get real-time collaboration, version history, comments, and conditional formatting out of the box — features most CMSes charge for. There are no new logins to manage and no vendor to onboard.
Why Sheets works for simple content
The use case that fits best is structured, tabular content with no complex formatting requirements: a team directory, a list of job postings, a product catalog, a simple blog with title/date/body columns. If your content fits in a spreadsheet, a spreadsheet is not a workaround — it's the right tool.
The practical advantages:
- Familiar editing UI. A non-technical editor can add a row, fix a typo, and reorder entries without touching a dashboard they've never seen.
- Built-in version history. Google Sheets keeps a full revision history. Rolling back a bad edit is faster than it is in most CMSes.
- Real-time collaboration. Multiple people can edit at once. Comments are native. You can leave a cell note explaining why a particular field has an unusual value.
- Instant structure changes. Adding a new column is adding a new field. No schema migration, no migration script.
Where it breaks down
Be honest about the limits before you commit:
- No rich text. Cells hold plain strings. If your content needs bold, links, or images inline, you either restrict authors to Markdown (and parse it yourself) or you need a real CMS.
- No media management. You can store image URLs in cells, but the Sheet doesn't host the images. That's a separate concern.
- No publishing workflows. There's no draft/published state unless you build one (a
statuscolumn with a value likepublishedthat you filter on). There are no approval flows, no scheduled publishing. - Scale limits. Google Sheets has a 10 million cell limit and starts to slow down well before that. For a blog with a few hundred posts this is irrelevant; for a product catalog with 50,000 SKUs, use a database.
The sweet spot is content that a small team manages, changes infrequently, and fits naturally in rows and columns.
Serving it with SheetsAPI
GKit SheetsAPI turns the Sheet into a proper REST endpoint without any backend code. Connect your Sheet, get a userKey, and your content is immediately available over HTTP as JSON. The first row of each tab becomes the field names; every row below it becomes an object.
A Sheet with tabs named Posts and Authors becomes:
GET /api/spreadsheets/{userKey}/Posts
GET /api/spreadsheets/{userKey}/Authors
You can filter, sort, and paginate in the query string — no custom backend needed for the common cases.
A real Next.js example
Say your Posts tab looks like this:
| slug | title | date | body | status |
|---|---|---|---|---|
| hello-world | Hello World | 2026-06-01 | First post content here. | published |
| draft-post | Coming Soon | 2026-06-10 | Work in progress. | draft |
Here's how you'd fetch published posts in a Next.js app using the App Router:
// lib/posts.ts
const SHEETS_API = "https://sheetsapi.gkit.mreshank.com/api/spreadsheets";
const USER_KEY = process.env.SHEETS_USER_KEY;
export type Post = {
slug: string;
title: string;
date: string;
body: string;
status: string;
};
export async function getPosts(): Promise<Post[]> {
const res = await fetch(`${SHEETS_API}/${USER_KEY}/Posts`, {
next: { revalidate: 60 }, // ISR: revalidate every 60s
});
if (!res.ok) throw new Error("Failed to fetch posts");
const data = await res.json();
return (data.rows as Post[]).filter((p) => p.status === "published");
}// app/blog/page.tsx
import { getPosts } from "@/lib/posts";
export default async function BlogIndex() {
const posts = await getPosts();
return (
<ul>
{posts.map((post) => (
<li key={post.slug}>
<a href={`/blog/${post.slug}`}>{post.title}</a>
<time>{post.date}</time>
</li>
))}
</ul>
);
}The revalidate: 60 gives you ISR — the page rebuilds in the background when content changes, so editors see their updates live within a minute without a manual deploy.
For individual post pages, use generateStaticParams to pre-render all slugs at build time, with the same 60-second revalidation to catch new posts.
When to upgrade
This pattern works until it doesn't. The signals that you've outgrown it: your editors want to write rich Markdown in a proper editor, you need per-post image uploads, or you want a preview mode that shows a draft before it's published. At that point, Sanity or Contentful are the natural next step. Until then, a Sheet + SheetsAPI is less infrastructure to maintain than any CMS you'd self-host, and your content team won't need a tutorial to use it.