Editorial illustration of a search result snippet being fine-tuned like an engine, with glowing JSON brackets and a small sitemap tree floating as mechanical parts

At 11 PM on a Sunday night, my blog's brand-new SEO endpoint answered my first real API call with a 403 Forbidden.

I had spent the evening shipping the feature — an endpoint that lets an API client read and update the site's SEO settings, plus per-post overrides — and the code was green. Two hundred and eighty-one tests passing. And yet there it was: the live server, calmly refusing to do the thing I had just taught it to do.

The fix took two minutes and taught me more than the hours of coding that came before it. But I'm getting ahead of myself.

What I actually built

My blog runs on ExploreCMS, the little Next.js CMS that powers this site. Until this weekend, SEO settings lived in the admin UI only — eight site-wide fields like the meta description, default OG image, and the Google verification token. Fine for a human clicking around. Useless for anything programmatic.

So I added a proper API surface:

  • GET and PATCH on /api/v1/seo for the eight site-wide settings
  • Per-post SEO overrides on the posts API: custom description, canonical URL, OG image, and a noindex flag
  • Validation with opinions — canonicals must be absolute URLs, because a relative canonical is an SEO bug dressed as a feature; OG images may be site-relative, because they resolve against the site anyway
  • Two new tools in the blog's MCP server, so an agent can manage SEO like any other resource instead of me clicking through an admin panel

Nothing here is exotic, and that's the point. SEO is ninety percent metadata plumbing — and plumbing should be reachable by pipe, not just by tapping on the faucet.

The 403 was doing its job

Here's the part I grew to like. The blog's API keys don't use wildcard permissions. Each key carries an explicit list: posts:read, posts:create, media:upload, and so on. When I added the new seo:read and seo:update permissions, I registered them in the codebase — but nobody had granted them to the key I was calling with.

So the live 403 wasn't a bug. It was least-privilege working exactly as designed. I had simply forgotten to finish the job: go into the admin, add the two new permissions to the key, try again. Round trip verified, settings flowing both ways.

Two lessons in that tiny failure:

  • Registering a permission in code and granting it to a key are different steps, and the second one is very easy to forget at 11 PM
  • A permission system that fails closed is annoying in the moment and priceless in the long run. The failure mode you want is "I can't do a thing I should be allowed to do" — never, ever the reverse

Teaching Google there's a person here

The second half of the sprint was structured data. The site has a public profile page, and I wanted search engines to understand it as a profile — not just another page of prose.

The mechanism is JSON-LD: a script block of schema.org vocabulary embedded in the page. The profile now renders a ProfilePage whose mainEntity is a Person, with a stable @id anchored at /profile#person. Job title, location as a PostalAddress, languages, education, topics, and sameAs links pointing at the person's other public profiles — all generated from the profile data already sitting in the CMS, so there's one source of truth and no second copy to drift.

Two details worth stealing if you ever build this:

  • The stable @id matters more than it looks. Without it, every page that mentions the person risks being read as a separate entity. With it, Google can stitch the knowledge graph together across pages.
  • The profile page was missing from the sitemap entirely. Every other route was in there; the page I most want indexed was absent from the crawlers' preferred map. One line fixed an indexing gap I didn't know I had.

The page title also changed shape — Name — Headline — City, Region — with a meta description generated from the actual summary text. A small change, but a title of "Profile" tells a search engine exactly nothing.

Proving ownership, the DNS way

Last step: Google Search Console. The site had never been verified, and I wanted the domain property — the one that covers every subdomain and both protocol variants in a single entry.

Domain properties require DNS-level proof, which turned out to be the smoothest part of the whole night. Search Console hands you a TXT record value; my DNS lives at Cloudflare; the record goes in at the apex; a few minutes later, verify. No meta tag to embed in templates, no HTML file to upload, no verification token rotting in a settings page.

Two things I learned from the fine print:

  • The record stays forever. Delete the TXT record and you revoke Search Console access. It's not a one-time handshake; it's a standing claim check.
  • Everything below the apex inherits the verification. Subdomains, www, http and https — one entry covers the lot. Submit the sitemap, request indexing on the pages that matter, done.

The boring truth about SEO

By half past midnight the whole loop was closed: settings manageable by API, the profile legible to knowledge graphs, sitemap complete, ownership verified, first indexing requests in.

What surprises me every time I touch this stuff is how unglamorous it is. There's no SEO sorcery here — just metadata that agrees with itself, structured data that tells the truth, and a TXT record that says "yes, this one is mine." The discipline is the product.

The interesting question is the one I can't answer yet: does any of it move the needle? Search Console will tell me in a few weeks. Plumbing first, results later — that's the order these things go in.

When did you last check whether your own site is actually telling search engines the truth?