How I built this site... or should I say guided my agents to build it?

Discover how to build a fast, database-driven site with a static shell and cache-driven fragments using Next.js, Cache Components, and AI-assisted workflows.

AI has made it so you don’t really need to use blogging platforms to spin up decent performing media sites.

I decided to get this site up from scratch because I wanted a place to test a few ideas I didn’t trust in a “real” product yet: Next.js Cache Components, server actions as the entire backend, and AI-assisted authoring that stays inside the editor instead of bouncing between tools.

The goal: database-driven, but static-fast

The site has posts, comments, contact submissions, feature flags, an admin panel, and image uploads. It is not a static blog generator. It also cannot feel like “a web app” in the bad way where navigation is snappy but the first paint is slow and every page depends on client-side hydration.

So the goal became: serve a static shell, stream or cache the dynamic parts, and make updates show up immediately when I publish. Not “wait for a revalidate timer.” Not “kick off a build.” Just publish and let the next request do the right thing.

That goal drove almost every decision. The stack is common. The way the pieces combine is the interesting part.

The stack, with the boring parts on purpose

I used Next.js 16 with the App Router, React 19, TypeScript, and Tailwind CSS 4. Data lives in PostgreSQL. Images live in Cloudflare R2. All backend logic is server actions. There is no separate API layer.

These are not exotic choices. I actually wanted them to be boring. A personal site is the wrong place to invent new infrastructure, but it is a great place to pressure-test architecture decisions you might want later.

The places I did take a stance were: Drizzle over Prisma, Postgres as the one datastore, Cache Components as the default rendering model, and AI as a workflow tool rather than a “write the whole post” button.

Drizzle ORM: SQL without the ORM tax

I picked Drizzle for one simple reason: I can read what it does. When I’m building a content system, I don’t want an ORM that slowly becomes a second language with its own edge cases and escape hatches.

Drizzle schema definitions are just TypeScript. That seems minor until you realize it changes how you iterate. The schema is the source of truth, and it stays where the rest of the code lives.

Best part is AI knows your schema. It can spin up a CMS customized to your content model in a snap of a finger. Love this.

PostgreSQL: one database, fewer moving parts

Postgres stores everything: posts, comments, contact form submissions, site settings, and IP-based spam blocking. No Redis. No separate key-value store. No external comment platform.

This is not because those tools are bad. It’s because “one more service” has a real tax in a small system. Another service means another operational surface area, another failure mode, and another place where your data model has to split. For a personal site, that is rarely worth it.

The site settings table is intentionally unglamorous. It is basically key-value pairs. But it lets me toggle features like comments on and off without deploying. Combined with cache tags, changing a setting can invalidate exactly the caches that depend on it.

Cache Components: the decision that changes the architecture

Cache Components were the most impactful choice in the entire build. Flipping them on changes how you think about pages. The page stops being “dynamic or static.” It becomes a composition of parts, each with its own caching and invalidation behavior.

The mental model I landed on is: build a static shell that can be served instantly from a CDN if you so choose, then decide which fragments should be cached into that shell and which should stream in. You stop arguing about “SSR vs SSG” and start designing cache boundaries.

On my homepage, most of the page is effectively static. Navigation, hero, service grid, and the structure of the article trays do not change every second. Those are server components, many of them cached. They prerender cleanly, and the build output stays fully static for the route.

Cache tags: shipping fast without serving stale content

The trick is not caching. Caching is easy (eye roll). The trick is invalidating caches without turning the system into a collection of timers and cron jobs.

I leaned hard on tag-based invalidation. When something changes, the server action that made the change also updates the relevant tags. Next request gets fresh data and re-caches the fragment. That keeps the site fast without making “freshness” a background job problem.

The granularity matters more than people expect. Publishing a new post should refresh post lists and the new post page, but it does not need to invalidate comment caches everywhere. Approving a comment should only refresh the comment fragment for that one post. If you get this wrong, every small change becomes a mini traffic spike to your database.

  • updateTag(‘posts’) expires post lists site-wide.
  • updateTag(‘post-{slug}’) expires a single article’s cached content.
  • updateTag(‘comments-{slug}’) expires one post’s comments.

This is the “systems over heroics” version of performance work. You do not win by hand-optimizing one query. You win by making it hard to do the wrong thing.

Keeping client bundles small: default to server, earn interactivity

Cache Components also forced a discipline I wanted anyway: client components are not the default. If something can be server-rendered, it should be. If it needs interactivity, make the interactive part as small as possible.

I hit this immediately with a classic mistake: a hero section marked 'use client' only because the image needed an onError fallback. That one small requirement pushed the entire hero into the client bundle. The fix was simple: extract the image into a tiny client component and keep everything else as server-rendered HTML.

This pattern repeated across the site. Anything that “looked interactive” got challenged. Most of it wasn’t actually interactive. Once those components became server components, the client bundle shrank and the static shell became more valuable.

Server actions as the backend: fewer layers, fewer lies

There is no API route layer in this site. Every mutation goes through server actions. Creating posts, uploading images, moderating comments, submitting the contact form, generating AI drafts, generating images… all server actions.

That removes an entire translation layer. No REST shapes to maintain. No route handlers that repeat auth and validation patterns. No client-side fetch wrappers. The “backend” becomes a set of functions with clear inputs, Zod validation, auth checks, and database calls.

The contact form is the best example because it is so boring it is almost suspicious. Parse form data. Validate. Insert. Done. It feels like cheating, which is usually a sign you removed something you didn’t need.

AI in the authoring workflow: leverage, not magic

I added AI in two places, and I was picky about the boundary. AI should reduce friction in the parts of writing that are mechanical. It should not replace taste, voice, or judgment. If it does, you get content that reads fine but says nothing.

The first integration is drafting and outlining. The admin panel has a multi-step generation wizard: topic, optional references, and then a structured draft comes back with an outline, section headings, image placeholders, and citations. The key word is “draft.” It is scaffolding, not a finished post.

Implementation detail that mattered: structured outputs with a schema. I do not want free-form text that sometimes forgets sources or misses the image prompts. A schema forces the model to return something I can trust mechanically, so my effort goes into editing and improving ideas.

Image generation that stays in the editor

The second integration is images. The workflow is: generate a primary image concept, generate inline images for the placeholders, upload them straight to R2, and insert the URLs back into the editor. No downloading files. No re-uploading. No folder full of “final_final_v3.png.”

This sounds like a small convenience, but it changes how often you actually use images. When images are a 30-second step, you add them thoughtfully. When images are a 10-minute step, you skip them or rush them.

The takeaway: remove layers until the system can breathe

The interesting part of the stack is not Next.js, Postgres, or Tailwind. Those are table stakes. The interesting part is what happens when you combine: Cache Components for a fast shell, Drizzle for readable SQL with types, server actions to eliminate the API layer, and AI tools that reduce authoring friction without pretending to be the author.

Every piece was chosen because it removed a layer of indirection. No ORM abstraction hiding the SQL. No API routes sitting between UI and database. No build step between publishing content and invalidating caches.

The fastest architecture is usually the one with the fewest things between the user and the content… and the one where the system makes the right thing easy by default.

Written by Adib Kadir. Product and engineering executive focused on rolling out AI at enterprise scale.

Start a conversation