Back in May I asked myself a question I probably should've asked earlier: does adding more posts to my static site make it slower over time? I had a picture in my head of the site slowly putting on weight.

It doesn't. But the question sent me off researching something I'd heard about for years and never really understood: the headless CMS. I wrote up what I found, decided I didn't need one, and left the draft sitting in a folder.

Four months later, work handed me a project that actually needed one. So this post is in two parts. Part one is me in May, working out what a headless CMS is and whether my blog needs it. Part two is me in September, using one in production for the first time. Spoiler: it was a spreadsheet.

Part one · May

The Question I Started With

A static site on a CDN doesn't slow down as you add pages. Each HTML file is served on its own. Azure Static Web Apps deploys atomically: the old version stays live until the new one is ready, then it switches over. Adding more pages doesn't change that.

📦 ELI5: what is a CDN?

CDN stands for Content Delivery Network. Think of a chain of convenience stores instead of one big supermarket. If there's only one supermarket in Auckland, someone in Christchurch waits for their milk to travel down. A CDN puts a copy of the milk in a store near everyone.

Azure SWA puts your HTML files on Microsoft's global CDN, so readers get it from a nearby edge location rather than one distant server. Adding more files doesn't make any single file slower to load.

For context, here's how I publish. Every post is a single HTML file in blog/posts/ in my GitHub repo. Drafts sit in blog/posts/Draft/ so they aren't public. When one's ready, I move it up a level, add a card to index.html and blog.html, and push to main. GitHub Actions deploys it to Azure SWA. There's no database, no server and no build step. Just files on a CDN.

The downsides are real but small. Moving files and updating index cards is manual. If I wanted to change how every post card looks, I'd be editing every file. That's about the whole list.

So What Is a Headless CMS?

People in web dev talk about headless CMS like it's obviously the right way to manage content. I'd absorbed the phrase without absorbing the meaning, so I went and looked it up properly.

📖 ELI5: explain it like I'm not a web developer

Imagine a restaurant. A traditional restaurant has the kitchen attached to the dining room. The chef cooks, the food comes out through a door, and the waiter brings it to your table. The kitchen and the dining room are the same building.

Now imagine a ghost kitchen. The chef still cooks, but the food gets picked up by delivery drivers and ends up at other restaurants, at people's homes, on catering trays. The kitchen doesn't care where the food goes. It just prepares it and hands it off.

A traditional CMS like WordPress is the restaurant with the dining room attached. It manages your content and also decides how it looks to visitors. A headless CMS is the ghost kitchen. It manages your content and hands it out through an API, so your website (or app, or anything else) can fetch it and display it however it wants. "Headless" just means there's no built-in front end. You bring your own.

In practice, a headless CMS gives you a web-based editor to write and manage content. It stores that content and serves it through an API. Your website calls the API, either when it builds or when a visitor loads the page, and renders the content however you like.

WordPress vs My Setup vs Headless CMS

It helped to put three models side by side, because "just use WordPress" and "just use a headless CMS" get lumped together even though they solve different problems.

Question WordPress (traditional CMS) My setup (hand-coded HTML) Headless CMS
Where does content live? MySQL or MariaDB database on a server HTML files in a GitHub repo Usually a content store run by the CMS (hosted or self-hosted). Git-based ones keep files in your repo.
How does a visitor get a page? Server renders PHP on every request (or cached) CDN serves a static HTML file directly Your front end fetches content from the CMS API and renders it (at build time or request time)
Writing experience WYSIWYG editor in the WordPress dashboard Writing HTML in VS Code Rich text editor in the CMS dashboard
Design control Theme-based. Customisable through the theme and site editor, with plugins for anything beyond that Complete. Every pixel is deliberate. You own the front end, but anything bespoke has to be modelled as a content type or component first.
Hosting cost Needs a server that runs PHP and MySQL/MariaDB, usually paid hosting $0 on Azure SWA free tier Front end: $0 on SWA. The CMS ranges from free tiers to very expensive, depending on vendor and plan
Dependencies Server, PHP, MySQL/MariaDB, regular updates GitHub, Azure SWA. That is it. GitHub, Azure SWA, CMS API (an external dependency)
What if the service behind it disappears? Site goes offline until you restore the database and files somewhere else Site goes offline, but it's just files in GitHub, so any static host can serve it again The front end moves like any static site. If the CMS goes, pre-built pages keep working but you can't publish. Pages that fetch at request time have nothing to fetch.

What jumped out at me: a headless CMS fixes a problem I have (writing in HTML isn't fun) by adding something I don't have (an external service I don't control). Whether that's a good trade depends on how big the writing problem really is.

There Are Plenty of Options

If you go looking, there's no shortage of headless CMS products. Some are hosted services with a free tier, like Contentful and Sanity. Some are open source and you run them yourself, like Strapi. And some, like Decap CMS, skip the database altogether and commit your content straight into your Git repo as files.

I haven't used any of them, so I'm not going to rank them. The one thing I took away from a surface-level look is that the Git-based ones sit closest to how my site already works. But for my site, using one would still mean adding a step that turns content files into HTML, and I don't have one. That gap is the real cost, not the CMS itself.

What I'd Gain and What I'd Lose

I'd gain a nicer writing experience, index pages that update themselves, and content that's structured enough to move somewhere else one day.

I'd lose simplicity, which I value more than I expected. Right now there's no API to go down, no vendor pricing change to worry about, and no webhook to break. Every post is plain HTML with no CMS behind it, so it'll still render in ten years.

I'd also lose some freedom at the post level. My posts have callout boxes, comparison tables, architecture diagrams and ELI5 sections like the ones above. With a headless CMS I'd still own the front end, but each of those would need to be modelled up front as a component the editor understands. Right now I just ask for whatever this particular post needs. A CMS editor handles headings and paragraphs well. Anything I haven't modelled yet means shortcodes or raw HTML pasted into the editor, which defeats the point of having one.

Does a CMS Even Make Sense With AI?

There's something I skipped over. I don't actually write my posts in HTML. I write rough notes and arguments, then ask AI (usually Claude) to turn them into a finished HTML file that matches my design system. The callouts, the tables, the colours and the typography all come from that. I supply the content and the intent.

That changes the maths for someone like me. The main thing I wanted from a CMS was a comfortable writing experience. AI already gives me that, without the limits of a CMS editor. If I want a comparison table with alternating rows or a pull quote in Cormorant Garamond, I describe it and it gets built, using the same design tokens as every other post.

For me, AI already solved the problem a CMS promises to solve. It just didn't come with a vendor.

The catch is the "for me" bit. This works because I'm comfortable reviewing HTML and pushing to GitHub. It says nothing about someone who isn't. I didn't think much about that in May. I thought about it a lot in September.

The Fix I Actually Need Is Small

That leaves the index cards. Updating them by hand is fine for now. At 50+ posts it'll be tedious, and that's the one scaling problem that's actually real.

My May draft solved this with a whole pipeline: write in Google Drive, sync it into the repo with rclone on a nightly GitHub Action, regenerate the index, deploy. I've since gone off that idea. It meant a Google Cloud service account, a key stored as a GitHub secret, and a sync job running every night, all so I could write somewhere other than my editor. But I don't write anywhere other than my editor. I write with an AI coding agent (Kiro) inside the repo, so the draft is already where it needs to be.

What I actually need is a small script that reads the meta tags every post already has and regenerates the cards in blog.html and index.html. The agent I'm already working in can run it, either through a hook when a new post lands or by asking me whether to re-index before I push. Yes, that's technically a build step creeping in. It's a small one I control, and at one post a fortnight I don't need a pipeline. I need a reminder.

So what would actually push me to a CMS? Posts that are mostly plain text, where an editor's limits don't matter. Wanting the same content in a newsletter or somewhere else. Tags and series that need proper filtering. Or the big one: someone else needing to publish who can't be expected to touch HTML or GitHub. None of those applied to my blog in May, so that's where I left the draft.

Part two · September

Then Work Handed Me the Exact Trigger

Four months later, that last trigger went off, just at work rather than on this blog.

We wanted a simple kiosk page for our drivers: the links they use every day (rosters, payslips, training, incident reporting) plus a bit of company news. The kiosk page itself is public with no login, on purpose. Drivers shouldn't need a password to find the roster link. The systems it links to still have their own sign-in. The person keeping it up to date works in marketing. They're not technical, they should never have to open GitHub, and they definitely shouldn't have to message me every time a headline changes.

So this was a real CMS use case. I still didn't want to adopt a CMS product for one page. SharePoint was the obvious corporate answer, but the route I'd have taken meant calling Microsoft Graph from something other than the browser. Even done properly, with a managed identity and permissions scoped to a single site, that's a server-side function and an identity with access into our tenant, sitting behind a public page, to serve content that was never sensitive. Way too much security and network overhead for a list of links and some news blurbs.

The answer was a spreadsheet

Google Sheets turned out to be a pretty good headless CMS for this. Everyone already knows how to edit a spreadsheet. The Sheets API returns the whole thing as JSON. And the content was going on a public page anyway, so there was nothing confidential to protect.

The sheet has three tabs. Config is two columns, key and value, for the headline, the alert banner and the colour theme (so the kiosk can go pink for an awareness week without a deploy). News is one row per story, with an active column to pull a story without deleting it. Links is one row per resource tile. The site is still a static build on Azure SWA. It fetches the sheet from the browser each time the page loads, so a change shows up on the next refresh.

The whole "CMS client", more or less
const url = `https://sheets.googleapis.com/v4/spreadsheets/${SHEET_ID}?includeGridData=true`;
const res = await fetch(url, { headers: { "x-goog-api-key": API_KEY } });
const { sheets } = await res.json();
// One call, every tab: Config, News, Links. Each cell gives its text, link and formula.
// Rows become objects keyed by the header row, merged over hardcoded defaults.
// A blank cell keeps the default, so most typos fall back instead of blanking the page.

That last comment is the design decision I'd repeat. Every field has a hardcoded default. If the sheet's unreachable, a cell's empty, or someone renames a tab, the page quietly shows the defaults instead of breaking. For a kiosk nobody's watching, a stale page that works beats an error screen.

The gotcha: an API key isn't permission

I enabled the Sheets API in Google Cloud, created an API key, restricted it to the Sheets API and our site's domain, added it to the GitHub secrets, and deployed. The page loaded fine and showed the defaults. There was no visible error because I'd built it to fail quietly, which is exactly why it took me a moment to notice.

The network tab told the real story. Every call came back with 403 PERMISSION_DENIED: The caller does not have permission.

My assumption was simple. I own the sheet and I made the key in my own Google Cloud project, so the key can read my sheet. That's not how it works. The sheet was still private, and an API key isn't a person.

🔑 ELI5: API key vs permission

An API key is like a library card with your project's name on it. It tells the library who to count the visit against, and lets them cut you off if you borrow too much. It doesn't prove who you are. Google's own documentation says an API key "doesn't identify a principal", meaning it isn't attached to any user account.[1]

So when the key asks for your private sheet, Google has no one to check permissions against. It can only hand over things anyone could see without logging in. Google's guidance says API keys are for "publicly available data", like files shared with "Anyone on the Internet with this link".[2]

If you really need to read a private sheet, you need an identity instead of a key. Either the person viewing the page signs in with their Google account, or a service account the sheet is shared with reads it for you. The first means every driver signs in to see a list of links, which defeats the point of the kiosk. The second needs a credential that can't live in a public web page, which means a server, and that's exactly the overhead I was trying to avoid.

The fix took ten seconds: Share → General access → Anyone with the link → Viewer. Viewer, never Editor. The marketing person still has edit access because they're added by name. I refreshed and the kiosk picked up the sheet straight away.

⚠️

Read the error code, because Google tells you which problem you have. API_KEY_INVALID means the key itself is wrong. A message about requests from a referrer being blocked means your domain restriction doesn't match the site. 403 PERMISSION_DENIED with "the caller does not have permission" meant, in my case, the key was accepted and the sheet said no, so check the sharing. It's natural to suspect the key first. In my case it was the one thing that was working.

The flip side: everything in that sheet is public. Anyone who finds the sheet ID can read it, and the API key ships inside the page's JavaScript for anyone to see. That's fine for kiosk headlines. It's not fine for anything you wouldn't print on a poster. Restrict the key to the Sheets API and your domain, and send it in the x-goog-api-key header rather than the URL, where keys tend to get logged and scanned. The domain restriction stops other websites using it, not someone with a script who fakes the header, so the realistic worst case is someone burning through the project's per-minute read quota. That's a risk I'm happy to live with. Then put "this sheet is public" at the top of the editor guide.

Public doesn't mean nothing can go wrong, though. What's in the sheet isn't secret, but it still matters that it's right. If someone got into the editor's Google account, they could swap a roster link for a phishing page. No validation fully stops that, because a phishing page can be a perfectly valid https:// link. What I can stop is anything in the sheet running on the page. Every cell renders as plain text, never as HTML. Links and images only render if they're https://, a path on our own site, or an anchor, which blocks javascript: links and sneaky //other-host links dressed up as local paths. Colours have to be hex codes, and icons come from a fixed list. The worst a bad sheet edit can do is change what the page says and where its links go. It can't change what the page runs.

The smaller gotchas: your editor is a human

Once the data was flowing, every remaining problem came from the fact that a person, not a program, was typing into the CMS. None of these are hard, and all of them came up while getting it set up.

🔤

Tab names with invisible spaces. A tab called "Links " with a trailing space is a different tab to the API. Match tab and column names ignoring case and whitespace, because nobody can see a trailing space.

📋

Documentation leaking into data. I told the editor to separate bulletin items with a pipe character, in a Markdown doc where the pipe was escaped as \|. That's exactly what got pasted into the sheet. The parser now accepts both.

🔗

People add links the way spreadsheets taught them to. They select a cell and press Ctrl+K, or type =IMAGE("…") to see a preview. The simple values endpoint can return formulas if you ask for them, but not a link added with Ctrl+K, because that's stored as cell metadata rather than the cell's value. Switching to spreadsheets.get with grid data returns the hyperlink and the formula, and gets every tab in one request instead of one per tab.

📝

Instructions belong in the sheet, not only in a doc. Each tab now has a second row starting with # that explains every column, and the code skips any row starting with #. The help text sits right above where they type.

💡

Part two lands on the same point as part one, from the other side. In both cases, the CMS value that mattered wasn't the database or the API. It was giving someone a writing interface they already understand. For me, that's AI and a Git repo. For one page and one marketing editor, it was the spreadsheet they already use every day.

Where I land

My Verdict

My blog still doesn't need a headless CMS, and I'm not going to qualify that to death.

What changed between May and September is that I stopped thinking about CMS as a product and started thinking about who's editing. When it's me, AI and a Git repo do the job with fewer limits than a CMS editor would. When it was a marketing colleague at work, the answer still wasn't a CMS product. It was a Google Sheet, a key/value tab, and a page that falls back to sensible defaults when a human does something human.

That has limits, and I can see them from here. Editors who need different permissions, several pages each with their own content structure, or content that needs approval before it goes live, and a spreadsheet stops being the answer. That's when I'd look at a real CMS again.

But if you're about to reach for a headless CMS because one non-technical person needs to edit a handful of fields on a public page, try a spreadsheet first. Just remember to share it.

References and further reading

1. Google Cloud, "Manage API keys". Standard API keys associate a request with a project for billing and quota, but do not identify a principal, so IAM cannot check whether the caller is authorised. docs.cloud.google.com/docs/authentication/api-keys
2. Google Workspace, "Create access credentials". API keys are for anonymously accessing publicly available data, such as files shared with "Anyone on the Internet with this link". developers.google.com/workspace/guides/create-credentials