Using Google Sheets as a CMS in Astro
Build a content-driven Astro site that reads data from Google Sheets via the GKit SheetsAPI - with server endpoints, type-safe collections, and edge-ready caching.
Most small content sites do not need a full headless CMS. The editorial team already lives in Google Sheets. The data is already there. The question is how to get it into your Astro pages without writing a bespoke Google Sheets integration, managing OAuth credentials, or paying for another SaaS subscription.
GKit SheetsAPI solves this: it gives you a standard REST endpoint in front of any Google Sheet, authenticated with a bearer token, with filtering, sorting, and pagination built in. From Astro's perspective it looks like any other JSON API.
Setting up Astro for server rendering
Astro supports static output by default, but to fetch live data from GKit - rather than baking content at build time - you need server-side rendering. Set output: "server" in your config and add an adapter for your deployment target.
// astro.config.mjs
import { defineConfig } from "astro/config";
import node from "@astrojs/node";
export default defineConfig({
output: "server",
adapter: node({ mode: "standalone" }),
});If you are deploying to Cloudflare Pages, swap the adapter to @astrojs/cloudflare. The rest of the code in this post is adapter-agnostic.
TypeScript types for the GKit response
Every GKit SheetsAPI response follows the same shape regardless of which sheet you are reading. Define the types once and reuse them across your project.
// src/lib/gkit.ts
export interface GKitMeta {
total: number;
limit: number;
offset: number;
}
export interface GKitResponse<T> {
data: T[];
meta: GKitMeta;
}
// Shape this to match the columns in your sheet
export interface Post {
slug: string;
title: string;
body: string;
published_at: string;
tags: string;
}
const BASE_URL = "https://api.gkit.io/api/spreadsheets";
const USER_KEY = import.meta.env.GKIT_USER_KEY;
const API_KEY = import.meta.env.GKIT_API_KEY;
export async function fetchSheet<T>(
sheetName: string,
params: Record<string, string | number> = {},
fetchOptions: RequestInit = {},
): Promise<GKitResponse<T>> {
const url = new URL(`${BASE_URL}/${USER_KEY}/${sheetName}`);
for (const [key, value] of Object.entries(params)) {
url.searchParams.set(key, String(value));
}
const res = await fetch(url.toString(), {
headers: {
Authorization: `Bearer ${API_KEY}`,
"Content-Type": "application/json",
},
...fetchOptions,
});
if (!res.ok) {
throw new Error(`GKit error ${res.status}: ${await res.text()}`);
}
return res.json() as Promise<GKitResponse<T>>;
}Store GKIT_USER_KEY and GKIT_API_KEY in a .env file at the project root. Never commit them; add .env to .gitignore.
A server endpoint that lists posts
Astro server endpoints are TypeScript files under src/pages/api/. They export a GET function and return a Response. This is where you call GKit and add caching headers before the response leaves the server.
// src/pages/api/posts.ts
import type { APIRoute } from "astro";
import { fetchSheet, type Post } from "../../lib/gkit";
export const GET: APIRoute = async ({ url }) => {
const limit = Number(url.searchParams.get("limit") ?? 10);
const offset = Number(url.searchParams.get("offset") ?? 0);
const search = url.searchParams.get("search") ?? "";
const params: Record<string, string | number> = {
limit,
offset,
sort: "published_at",
order: "desc",
};
if (search) params.search = search;
const result = await fetchSheet<Post>("posts", params);
return new Response(JSON.stringify(result), {
headers: {
"Content-Type": "application/json",
// Cache at the CDN layer for 60 seconds, serve stale for 10 more
"Cache-Control": "public, s-maxage=60, stale-while-revalidate=10",
},
});
};The Cache-Control header is the simplest path to edge caching in Astro server mode. Cloudflare, Vercel, and most CDNs will cache the response for 60 seconds and serve stale content while revalidating in the background - reducing round-trips to GKit on high-traffic pages.
A dynamic blog page that reads a single post
With the helper in place, individual post pages are straightforward. Astro's [slug].astro file receives the slug from the URL, queries GKit for that row, and renders the result.
---
// src/pages/blog/[slug].astro
import Layout from "../../layouts/Layout.astro";
import { fetchSheet, type Post } from "../../lib/gkit";
const { slug } = Astro.params;
// GKit search matches against any column; filter client-side for exact slug
const result = await fetchSheet<Post>("posts", { search: slug, limit: 20 });
const post = result.data.find((p) => p.slug === slug);
if (!post) {
return new Response(null, { status: 404 });
}
---
<Layout title={post.title}>
<article>
<header>
<h1>{post.title}</h1>
<time datetime={post.published_at}>
{new Date(post.published_at).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
})}
</time>
</header>
<div set:html={post.body} />
</article>
</Layout>The search parameter on GKit does a broad text match. Since you want an exact slug, the small client-side find call ensures you get the right row. For large sheets, use fields=slug,title,body,published_at to limit the columns returned and reduce payload size.
Appending rows from a form submission
GKit's POST endpoint accepts { rows: [...] } to append data. This pairs well with Astro form endpoints - for example, storing newsletter signups or contact form submissions directly in a sheet without a separate database.
// src/pages/api/subscribe.ts
import type { APIRoute } from "astro";
const BASE_URL = "https://api.gkit.io/api/spreadsheets";
export const POST: APIRoute = async ({ request }) => {
const { email } = await request.json();
const url = `${BASE_URL}/${import.meta.env.GKIT_USER_KEY}/subscribers`;
const res = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${import.meta.env.GKIT_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
rows: [{ email, subscribed_at: new Date().toISOString() }],
}),
});
if (!res.ok) {
return new Response(JSON.stringify({ error: "Failed to subscribe" }), {
status: 500,
});
}
return new Response(JSON.stringify({ ok: true }), { status: 200 });
};The sheet gains a new row for every successful submission. No database migration, no schema file - just add a column to the spreadsheet header row when you need a new field.
When to graduate beyond Sheets
Google Sheets works well as a CMS when the content team is small, the data is relatively flat, and editorial velocity is moderate. It starts showing strain when you need relational queries, complex access control, or real-time collaborative writes at scale.
At that point you would typically move to a purpose-built headless CMS or a relational database. But for a blog, a product directory, a changelog, or an internal tool - Sheets plus GKit covers a surprising amount of ground with almost no infrastructure to maintain.
Get started
Sign up at gkit.io/signup to get your API key. The free tier covers up to 1,000 requests per day across all your sheets, which is enough to run a production content site at moderate traffic without spending anything.
Your Google Sheet becomes a live CMS endpoint in under five minutes. No OAuth dance, no service accounts, no Google Cloud Console - just your spreadsheet and a bearer token.