Why I built my own CMS (and yes, Sanity would have worked)
Two Next.js apps, Firestore, R2, and a custom revalidation pipe — built to learn engineering end to end, not because Sanity wouldn't have worked.
- read
- 5 min
I built tabsircg.com on a CMS I wrote from scratch. Two Next.js 16 apps in a monorepo, Firestore on the back, Cloudflare R2 for media, JWT auth in an HTTP-only cookie, presigned upload URLs, cross-app cache invalidation pushed over HTTP. The whole apparatus runs for one author and a handful of readers.
Obvious thing first: a hosted CMS would have been fine. Sanity, Contentful, Notion-as-a-database, plain MDX in the repo — any of them would have shipped this site faster. I knew that going in. I knew it more clearly halfway through. I picked the long road on purpose, and I want to be honest about why.
It's a learning project that happens to be real
This is my first project that isn't a demo. Not a tutorial follow-along, not a clone, not a portfolio-sized to-do app with three checkboxes and a fake login. Something I actually publish to, under my own name, that breaks in front of real users when I do something dumb.
I'd spent months reading the Next.js docs end to end. I'd read enough JWT explainers to write my own. I knew what ISR was on paper. But knowing what something is and having shipped it through one full revision cycle — broken it, debugged it, made the wrong call and lived with it for a week — are two different things. That was the gap I needed to close, once, end to end, in something that ships.
So I needed a project complex enough to touch all of it without being a to-do app cosplaying as work. The CMS was the excuse. Auth (JWT in a cookie, single tenant — no user table, just one username and password in env vars). NoSQL data modeling (Firestore, denormalized counters, composite indexes for queries like "give me the currently featured post"). Blob storage (Cloudflare R2 via presigned URLs, direct browser → bucket uploads). The full SSR / SSG / ISR matrix from one codebase. Two apps that have to stay in sync without a redeploy. All of it under one roof, end to end mine.
What the split actually buys you
The thing I'm most pleased with is the shape. Two Next.js 16 apps. One public, one private. They talk over REST with a shared secret. The admin owns everything that writes — Firestore, R2, the analytics aggregations. The portfolio reads, caches, and trusts.
When I publish a post in admin, it POSTs the affected cache tags to portfolio's /api/revalidate, which calls revalidateTag(tag, { expire: 0 }) and the public site sees the change on the next request. No redeploy, no webhook gymnastics. Two apps, one pipe:
// apps/admin/src/lib/blogUtils.ts — the sender
export async function revalidateBlog(slug: string) {
await sendRevalidateRequest({ tags: ["blogs", `blog:${slug}`] });
}
async function sendRevalidateRequest(target: RevalidateTarget) {
await fetch(`${env.BLOG_ORIGIN}/api/revalidate`, {
method: "POST",
body: JSON.stringify(target),
headers: { "Content-Type": "application/json", acs_tkn: env.SERVER_TOKEN },
});
}
// apps/portfolio/src/app/api/revalidate/route.ts — the receiver
export async function POST(request: NextRequest) {
if (request.headers.get("acs_tkn") !== env.SERVER_TOKEN) {
return new Response("Unauthorized", { status: 401 });
}
const { tags } = await request.json();
// { expire: 0 } purges immediately rather than serving stale.
for (const tag of tags) revalidateTag(tag, { expire: 0 });
return NextResponse.json({ ok: true });
}

The { expire: 0 } matters. Without it, Next serves one stale response before reading fresh. I learned that the way I learn most things — by shipping it, watching the stale page, and reading the docs again with the right question in my head.
To keep the wire types honest, the shared Zod schemas live in a workspace package that exports its TypeScript source directly — no build step, no dist. Both apps transpile it through Turbopack, so editing a schema hot-reloads in both dev servers at once. If I ever published that package to npm I'd have to do real exports conditions and a build step. It's internal-only, so it stays as raw .ts. That trick alone is something I wouldn't have found in a tutorial.
There are smaller decisions I keep coming back to:
The "featured post" flag is a
featuredAt: number | nulltimestamp, not a boolean. Highest timestamp wins. Means "feature this post" is one write, not two. It also means once you've featured anything, something is always featured. I considered the boolean and decided against it. That's the kind of call I want to be the one making.Blog content is stored as a
JSON.stringify'd Notion-style document on the way in, parsed on the way out. The encoding lives at the boundary, so the rest of the code only deals with the rich type. (The editor itself is a library I built separately — that's a different case study.)Reactions are device-scoped. The portfolio sets a
felt-idUUID cookie, proxies the tap through its own API so the cookie stays same-origin, and admin clamps the per-device count inside a Firestore transaction. Nothing exciting individually. Exciting because I had to sit and think about the seams.
A side effect I didn't plan for: when the CMS is yours, every detail of the public site is yours too. No template constraints, no escape hatches needed. That mattered less than I expected during the build, and more than I expected after.
The AI bit, which came later
Once everything underneath was stable, Claude got good enough that I wanted to try draft generation. I bolted on @anthropic-ai/claude-agent-sdk in admin, but locked it down hard: web search and web fetch only, no filesystem, no hooks, no MCP, no skills, all permissions denied by default.
// apps/admin/src/config/anthropic.ts
const ENABLED_TOOLS = ["WebSearch", "WebFetch"];
const DEFAULT_OPTIONS: Options = {
model: "claude-opus-4-7",
tools: ENABLED_TOOLS,
settingSources: [],
skills: [],
plugins: [],
hooks: {},
settings: {
disableAllHooks: true,
disableBackgroundAgents: true,
allowedMcpServers: [],
permissions: { deny: ["*"], defaultMode: "dontAsk" },
},
};
Output is forced through a Zod schema, parsed on receipt, written back as a normal draft so the publish flow doesn't even know AI touched it.
It's not the centerpiece. It's a feature I added once the seams were clean enough to bolt one on without rewriting anything. That, in retrospect, is the part of building it yourself I underestimated — the surface area you have to add new ideas to a system whose joints you wrote.
What I'd cop to if you asked me at a bar
The honest answer to "would I do it this way again?" is: probably, but I'd cut the timeline by half if I were less precious. The real cost wasn't writing code. It was overthinking the dashboard UI. Overthinking the server-action vs API-route split. Overthinking whether the analytics ingest should be batched. Overthinking the editor before realizing I should just publish something and iterate. Every classic beginner over-engineering trap — I walked into most of them. Some I'm still in.
The analytics endpoint and the aggregations are wired up; the client-side emitter isn't. I'll get to it before the next deploy. By the time I got that far I'd already learned the part I cared about.
If this is starting to sound like a lot for a personal blog — it is. The whole project is, on some level, an over-engineered answer to "can you actually do the thing." The answer turned out to be yes, and the only reason I can say that with any confidence is that I went and did it instead of reading another article about it. That's the trade I made. I'm fine with it.
Site's at tabsircg.com. Happy to talk through any of the decisions in more detail.