Stack · 07·2026 · 5 min

My Stack for SaaS

The boring, conventional stack I reach for, and why boring is the point.

People ask what I build SaaS with, usually hoping for a clever answer. There isn’t one. The stack is deliberately conventional, and the conventional part is the whole feature. I build alone or in a small team, with Claude Code doing most of the typing, and both of those facts push toward the same two choices: tools the model already knows cold, and a shape that lets one person carry a thing from requirements to production without a handoff bleeding information.

Here’s what I actually reach for, and where each piece earns its place.

The frontend spine

Next.js on the App Router, Tailwind v4, shadcn/ui. So far this is every AI-generated starter on the internet, and that’s fine. The part that matters is what sits on top.

Tailwind is set up with CSS variables as tokens, never raw hex. --status-ready, --status-held, --primary. A designer changing the ready-state green edits one variable and every badge, dot, and highlight in the app moves with it. I generate and tune those token sets with tweakcn instead of hand-writing HSL, so theming is a visual decision rather than a find-and-replace.

shadcn is the primitive layer, but the real unit of composition is a component registry. Base primitives, then domain components (a ShipmentCard, a BookingWizard), then assembled patterns like DataTable and StatusTimeline, published with shadcn build and pulled in with npx shadcn add. When the model composes a screen it loads the registry index, names and props and usage rules, instead of a dozen component source files. It does for the codebase what a design system does for a Figma file, and it keeps the AI from inventing a bespoke button every third screen.

One scar worth passing on: Tailwind v4’s @theme block evaluates at build time, and next/font/google injects its variables at runtime. The two can’t see each other. On TrustedGyn my fonts silently fell back to the browser default sans until I stopped routing fonts through @theme and set font-family directly in CSS. Nothing in the error log, just the wrong typeface. If your v4 fonts vanish, that’s why.

State and data on the client

Zustand for state, TanStack Query for server data, React Hook Form paired with Zod for anything with inputs.

Zod is the one I’d defend hardest. I put it at every boundary where untrusted data enters, forms and API responses, and let it reject anything that doesn’t match the shape. On VBS this fell out of the design work almost for free: every state machine I drew (a shipment goes on_vessel → at_wharf → ready → picked) became a z.enum of exactly those values. The dropdown with fixed options and the validation schema are the same constraint written twice, once for the user and once for the compiler. When the real API started returning longer names and empty fields than my mock data ever did, the schemas caught the mismatches before they reached a single screen.

TanStack Query I reach for the moment there’s a server, mostly so I stop hand-rolling cache invalidation and stale states. TanStack Table shows up only on the enterprise builds, where a courier might have 200 allocated shipments and a naive .map() falls over.

The data layer

Postgres, usually through Supabase. It gives me a database, auth primitives, and pgvector in one place, which for a solo build is one fewer integration seam to babysit. When I want typed queries beyond the Supabase client, Drizzle.

But the honest default is smaller than that. TrustedGyn is 347 doctors across 41 cities, and it’s a Next.js app reading a JSON file. No database, no ORM, shipped in a single sitting. A directory that fits in memory doesn’t need Postgres, and pretending it does is how weekend projects turn into month projects.

The interesting end of the spectrum was Cortex Panel, a RAG app with roughly 65,000 embedded chunks in Supabase pgvector. The 500MB free tier became a hard wall at 1536-dimension embeddings (935MB of vectors). The fix wasn’t a bigger plan, it was OpenAI’s text-embedding-3-small supporting Matryoshka dimensionality: request 512 dimensions instead of 1536, re-embed, drop under 300MB, no meaningful quality loss. A constraint I resented turned out to be a free optimization. Most of them are, if you let them push on the design instead of your credit card.

Auth, payments, the boring middle

Clerk for auth, Stripe for payments. I don’t have strong opinions here beyond one: don’t build either yourself. Sessions, password resets, webhook reconciliation, and PCI scope are all fully-solved problems where a clever in-house version is just a slower path to the same place with more bugs. This is the part of the stack I think about least, on purpose.

Hosting and the deploy loop

Vercel, push-to-deploy. The loop I want is the one TrustedGyn had: buy the domain, create the repo, deploy, wire the custom domain, all inside the same working session, so nothing sits in a “ready to ship but not shipped” limbo. Cortex Panel is also where I learned to close the barn door before the horse leaves, not after. It ran on Vercel for four days with an open API endpoint anyone could curl to burn my OpenAI credits, until I added an origin whitelist, IP rate limiting, and input validation. Deploy early, but budget a pass for the fact that “deployed” and “safe to be public” are different states.

Guardrails instead of vigilance

The rules I used to re-explain every session are tooling now. House conventions (no hardcoded colors, no raw fetch, every string through the translation layer) are ESLint errors that block the commit, not paragraphs in a prompt the model might ignore. Zod guards the data boundaries. A verify script gates every commit so most bad output gets caught before I even review it. Playwright covers the handful of flows that actually matter. The registry is a guardrail too, since a model that can only assemble registry components can’t wander off into custom UI.

The point of all of it is the same: encode the rule once, in a place that can’t be diluted by whatever else is in the context window, and stop spending attention policing it.

Why boring is the point

Two reasons, and they compound. A conventional stack is one the model has seen a million times, so it writes it correctly on the first try instead of hallucinating an API that doesn’t exist. And a small, familiar surface means fewer integration seams for one person to hold in their head. Novelty in the stack is a tax I pay on every prompt and every debugging session, for taste points nobody using the product will ever notice.

The leverage in my work isn’t down here anyway. It’s in the intent model that turns requirements into a typed contract, and the registry that turns that contract into screens. Those are worth being opinionated about. The framework underneath them should be the least surprising thing in the building.

What I reach for, by size

SizeStack
Weekend directory (TrustedGyn, adhdindian)Next.js + a JSON file + Vercel. No DB, no auth.
Real product with content or AI (Cortex Panel)Add Supabase/pgvector, OpenAI, rate limiting, a proper deploy hardening pass.
Enterprise, multi-role (VBS)Add the full registry, the intent model, TanStack Table, Zod-typed domain, role-based routing.

Same spine every time. I add weight as the problem earns it, and not a dependency sooner.

Published 07·2026 · 1,243 words · 5 min
All notes