JOSHUA R. BUNNELL

Durham, NC · joshua.r.bunnell@gmail.com

Writing

Article

Batteries Included, Code You Own. The Stack We Build Everything On.

The constraint on a small team is no longer whether you can build — it's whether your stack keeps the cost of the next feature near zero. A technical walkthrough of our stack — Next.js, Tailwind, shadcn, Convex, Resend, WorkOS — including the trade-offs most stack posts won't tell you about.

Joshua R. Bunnellengineering, startups, build vs buy, AI, developer tools

The most common question I got after publishing We Ditched Our CRM wasn't about the decision. It was about the mechanics: what do you actually build on that makes a six-week platform possible?

Fair question. The philosophy — build your own operating system instead of renting someone else's assumptions — only works if the economics underneath it work. If every feature costs weeks of plumbing, the argument collapses. So this piece is the technical companion: the exact stack we use, why each layer earns its place, and — because I'd rather you trust this post than agree with it — the honest trade-offs of every single one.

Here's the thesis before the tour. Every layer in this stack is chosen for two properties:

  1. Batteries included, code you own. Each tool collapses setup and maintenance cost to near zero without taking ownership away from you. You get the infrastructure of a big team on day one, and you keep the ability to change anything on day one thousand.
  2. Legible to AI agents. Every layer is strongly typed, convention-driven, extensively documented, and massively represented in the corpus that coding agents learned from. An agent can hold the entire system in its head — schema to pixel.

Neither property is remarkable alone. Together they compound: infrastructure you don't have to build, expressed in code an agent can extend safely, means the marginal cost of the next feature keeps falling as the system grows. That compounding is the entire reason a team of one or two can ship like a department.

A two-by-two map: "batteries included" against "code you own." Rented SaaS starts fast but stays stuck; building from scratch buys ownership slowly; this stack sits in the corner where finished infrastructure arrives as code you keep the right to change.

The stack: Next.js, Tailwind, and shadcn/ui on the front, Convex as the backend, Resend for email, WorkOS for auth, and an agentic build layer — Claude Code, with Railway for anything long-running. Here's the whole system on one page; the rest of this post walks it layer by layer, in order of importance.

The system end to end: a Next.js, Tailwind, and shadcn interface connected to a Convex backend by generated typed bindings, with Resend and WorkOS wired in as services, a nightly-export offramp to a ClickHouse warehouse for analytics, and Claude Code agents working across every box — one typed language from schema to pixel.

The Backend Is the Whole Game

Most stack decisions are reversible. Your backend isn't — it's the layer everything else conforms to. So it's the decision we scrutinized hardest, and the one I'll spend the most time defending: Convex.

Convex is a reactive, document-relational backend platform where everything — the schema, the database queries, the server functions — is TypeScript. Your data model is defined in code with typed validators. Server logic comes in three flavors with real guarantees: queries (pure, cached, reactive reads), mutations (atomic, serializable transactions — full ACID within a mutation), and actions (the escape hatch for calling external APIs). Your frontend gets generated, end-to-end type-safe bindings to all of it. There is no ORM, no API layer to hand-write, no type drift between database and UI.

The headline feature is reactivity, and it's worth understanding because it eliminates an entire category of engineering. When a client subscribes to a query, Convex tracks the exact set of data that query read. When any mutation changes that data, Convex re-runs the query and pushes the new result over a WebSocket. Automatically. That's the architecture — not a feature you configure, but how the system works by default.

Realtime is the feature every team wants, every product benefits from, and almost nobody builds — because on a conventional stack it's a project. On Convex it's a side effect.

The reactive loop: a client subscribes to a query, Convex records the exact read set it touched, a mutation commits atomically, and changed results are pushed to subscribers over the WebSocket — no polling, no cache invalidation, no pub/sub wiring.

Then there's what I'd call the standard-library effect. Convex Components are installable backend modules — sandboxed, with their own tables and functions — for the things you'd otherwise build badly under deadline: aggregates and counts that don't melt under load, rate limiting, durable multi-step workflows, cron and scheduled functions, full-text and vector search, file storage, migrations, presence ("who's online"), and a growing set of AI primitives like the Agent component for persistent-memory chat. On our portfolio of projects, features that would each have been a week of infrastructure — a RAG-backed assistant, live-updating dashboards, scheduled email digests — were each an afternoon.

Two more properties matter to the business side of this audience:

It's open source, with a caveat I'll state precisely. The Convex backend is source-available under the Functional Source License, which converts to Apache 2.0 after two years — "fair source," not OSI-approved open source on day one. The practical upshot: you can self-host the entire backend on your own infrastructure, backed by SQLite or Postgres, at zero license cost. We treat self-hosting as an insurance policy rather than a plan — Convex itself is candid that the managed cloud is the recommended path — but the exit existing changes the risk calculus of betting on a young platform.

The scaling story is real, with an asterisk. Reads scale beautifully: queries are cached, functions run in V8 isolates with effectively no cold starts, and the subscription infrastructure is designed for enormous fan-out. Read-heavy products — which is most products — ride that curve cheaply. The asterisk is writes. Convex uses optimistic concurrency control: mutations that contend for the same hot document will conflict and retry (Convex retries them automatically and safely, because mutations are deterministic), and each deployment class has bounded write throughput. A write-heavy workload — high-frequency event ingestion, a global counter everyone increments — needs deliberate design: sharded counters, precise read sets, precomputed aggregates. The tools exist, but it's engineering, not magic.

And the biggest honest caveat: Convex is not SQL, and pretending otherwise will hurt you. There are no ad-hoc joins, no GROUP BY, no analyst pointing a BI tool at production. Queries run against indexes you declare, inside hard limits (a query can't scan more than 32,000 documents, for instance) that force you to design access patterns up front — a discipline I've come to see as a feature, but a real constraint nonetheless. Analytics workloads belong in a warehouse. Convex's first-party answer is streaming export through Fivetran; we route around the ETL vendor entirely. A small scheduled worker — a few dozen lines we own, running on Railway — snapshots Convex (a nightly convex export → load) into ClickHouse, the open-source column store built for exactly the aggregations an operational database shouldn't be doing. At gigabyte scale, per-connection ETL pricing is the wrong shape — and ClickHouse extends the same property this whole stack is built on: open source, so the exit door stays open. The guardrails matter more than the vendor: the warehouse is never on the critical path (if it's down, dashboards go stale and nothing else happens), and nothing ever writes back to production. The loop even closes neatly — a scheduled Convex action queries ClickHouse and upserts a small snapshots table, so warehouse-computed analytics still render reactively in the UI. Convex as the operational system of record; ClickHouse for the analytical questions. Clean separation, each layer doing what it's actually good at.

The register and the ledger: Convex is the operational system of record, built for fast transactions and live UI; ClickHouse is the analytical store, built for arbitrary SQL over everything. A one-way nightly export — one small worker on Railway, the only custom code — feeds the ledger, summaries loop back into a tiny snapshots table so dashboards stay reactive, and the guardrails hold: the warehouse is never on the critical path and nothing writes back to production.

The lock-in question deserves one honest sentence: your data is portable (export anytime, self-host anytime); your code is not — queries and mutations are written to Convex's model, and porting to Postgres-plus-ORM would be a rewrite. We accepted that trade knowingly, because the alternative — hand-rolling websockets, cache invalidation, transactions, schedulers, and search on "portable" infrastructure — is its own multi-month lock-in, paid up front instead.

The Interface Layer: Own the Pixels

The front end is three decisions that function as one: Next.js for the framework, Tailwind CSS for styling, shadcn/ui for components.

Next.js is the least contrarian pick in this post, and that's precisely the point. It's the most heavily used React framework in the industry, MIT-licensed and self-hostable, with file-system routing, React Server Components, streaming, and — as of Next.js 16 — a caching model that's finally explicit and opt-in rather than implicit and surprising, plus Turbopack builds that are multiples faster. For agent-assisted development, ubiquity is a superpower: Next.js conventions are so deeply represented in model training data that agents produce idiomatic, working code on the first pass — and Next 16 even ships a first-party MCP server so agents can introspect routing and caching directly.

The honest costs: React Server Components are a genuinely harder mental model than the React of five years ago, and Next has an upgrade treadmill — major versions bring real breaking changes, and the caching story has churned more than anyone would like (v16's explicitness is an admission of that). We absorb the upgrades as routine maintenance; a team without frontend depth or agent leverage will feel them more.

Tailwind solves a problem that compounds quietly: divergence. Utility classes draw from a constrained design-token scale, so every margin, color, and type size across the entire codebase comes from the same small vocabulary. Tailwind v4 rebuilt the engine for near-instant builds and moved configuration into CSS itself. Yes — the markup is verbose, and designers rightly point out that a wall of utility classes is harder to read than well-named CSS. We consider that cost real and worth paying, because the constrained vocabulary is exactly what keeps a fast-moving codebase — especially an agent-extended one — visually coherent. Tailwind doesn't give you a design system; it gives you the grid paper to draw one on. The discipline is still yours.

shadcn/ui is the piece people most often misunderstand, and the one that best embodies the "code you own" thesis. It is not a component library you install. It's a CLI that copies component source — built on accessible primitives (Base UI by default for new projects as of mid-2026, Radix before that and still fully supported) — directly into your repository. There is no dependency to fight, no theme API to reverse-engineer, no override hell. When we want a component to behave differently, we edit the component, because it's our file.

The ownership boundary, compared: with a rented component library, your code negotiates with a vendor package through props, theme APIs, and CSS overrides; with shadcn/ui, the CLI copies the component source into your repository, where you can simply edit the file — with the honest trade that upstream fixes don't flow automatically.

The trade is stated in the same sentence: you own it, so you maintain it. Upstream fixes don't flow to you automatically; accessibility is inherited from the primitives and preserved — or regressed — by your edits; and across a long-lived codebase, copies can drift. For us the trade is asymmetric: agents are exceptionally good at exactly this kind of maintenance, and owned source is the most agent-legible dependency there is — the entire component is right there in context, not behind a compiled package boundary.

Email Without a Committee

Email is the classic "simple until you touch it" system. Resend is the developer-first email API, and the claim I'll defend is that a working, production-respectable email pipeline — verified domain, SPF and DKIM records, typed templates, delivery webhooks — genuinely takes one sitting to stand up. Templates are written with React Email, which means our emails are components in the same repo, same design tokens, same review process as the rest of the product. No drag-and-drop template editor slowly drifting away from brand.

Honesty section: Resend is young. Postmark has spent a decade earning the best deliverability reputation in the business by being ruthlessly transactional-only; Resend is newer, runs atop AWS SES infrastructure, and its shared-IP reputation — while strong — has less history behind it. And no provider exempts you from the physics of email: domain warmup, suppression lists, keeping marketing and transactional traffic separated, and complaint hygiene are your responsibility on every platform. We chose Resend because the developer experience converts email from a quarterly project into a routine pull request — and at our volumes, deliverability differences between the serious providers are marginal. At millions of emails a month, run your own bake-off.

Auth Is a Solved Problem. Stop Re-Solving It.

Authentication is the highest-stakes, lowest-differentiation code you can write. Nobody chose your product for your login page, and one mistake there erases trust you can't rebuild. So we outsource it — carefully — to WorkOS.

The pricing headline is genuinely startling, so let me state it precisely, because the imprecise version gets repeated too often: AuthKit — email/password, social login, passkeys, MFA, magic links, plus role-based access control and organizations — is free up to one million monthly active users. That is not a trial; it's the published pricing. What's metered is the enterprise layer: each customer's SSO or SCIM directory connection bills per-connection (starting around $125/month, with volume tiers down). Which is, frankly, the correct shape for a startup — you pay nothing while you find product-market fit, and the costs that appear later appear because you're closing enterprise customers, attached to exactly the revenue that funds them. If you're evaluating alternatives, Clerk's free tier is comparably generous with best-in-class prebuilt UI; we still pick WorkOS for the architecture, which is the part I actually care about.

WorkOS pricing, stated precisely: self-serve authentication — email and password, social login, passkeys, MFA, magic links, RBAC and organizations — is free to one million monthly active users; the metered layer is enterprise SSO and SCIM connections at roughly $125 per connection per month with volume tiers, costs that appear only when you close enterprise customers.

WorkOS doesn't demand to be your user database. It handles the protocol work — the OAuth dances, SAML, passkeys, session tokens — and hands you standard JWTs and signed webhooks (user.created and friends) that you wire into your own backend. Our users live in our Convex tables, under our schema, with our roles. That's the difference between renting a capability and surrendering a system of record. It's also why the integration is so agent-friendly: it's conventional REST, JWT verification, and webhook handlers — patterns coding agents have seen a million times — against documentation dense enough to ground on. Convex has made WorkOS its default auth in new projects, so the two halves of this stack are now first-class citizens of each other.

The trade-off is inherent, not incidental: auth becomes an external dependency on someone else's uptime, and if you someday leave, migrating identities is real, delicate work. We judge that risk smaller than the risk of a two-person team maintaining its own password infrastructure. I hold that position without much anxiety.

The Fourth Layer: Agents Are Part of the Stack Now

Here's the part most stack posts still treat as a footnote. Every choice above was partly evaluated on a question that didn't exist a few years ago: how well can an AI agent work on this?

Because in practice, agents write most of this code. Our development loop runs through Claude Code — against a stack that is, by construction, maximally legible to it. One typed language from schema to pixel. Conventions instead of bespoke architecture. Components that live as readable source in the repo. Documentation dense enough that an agent can verify itself against it. When people ask how the timelines in the last post were possible, this is the unglamorous answer: we didn't just adopt AI tooling — we chose a stack the tooling is fluent in.

The build loop with agents in it: a human intent like "add quoting to the studio" goes to a Claude Code agent grounded in the repo, passes through typecheck, lint, and test gates, then human review before shipping to main — with feedback looping back into the next intent. The loop runs on a laptop, in CI with headless auth, or on a Railway worker for long-running jobs.

Two operational notes for teams heading this way. First, agentic work needs somewhere to run beyond your laptop: background agents, scheduled jobs, long-running processes that outlive a serverless function's timeout. That's what Railway is in our stack — container-based hosting where a worker just runs until it's done, priced by usage, standing next to Vercel's serverless frontend rather than replacing it. Second, Claude Code authenticates headlessly against a subscription or API key, which makes CI and automated runs first-class rather than a hack.

And the honest framing: this layer is a practice, not a product. Agent-assisted development amplifies whatever discipline you already have — typed schemas, tests, review — and amplifies the absence of them just as efficiently. The stack makes the leverage possible. It doesn't make it automatic.

When This Stack Is the Wrong Call

Credibility requires naming the cases where I'd choose differently.

  • Analytics-first products. If your core product is heavy OLAP — arbitrary joins and aggregations over huge datasets — a document-relational operational store is the wrong center of gravity. Start with Postgres or a warehouse-native design.
  • Extreme write-heavy ingestion. Firehose telemetry and high-contention counters will meet Convex's OCC and throughput ceilings early. Purpose-built ingestion infrastructure wins.
  • Hard compliance perimeters. If a regulator or procurement checklist dictates specific residency, certifications, or on-prem topology, verify every vendor here against it before you commit — being young is a real property of half this list.
  • Teams that can't carry owned code. shadcn and agent-maintained components assume someone — human or agent — actively tends the code you own. An org that wants vendor-supported everything should buy vendor-supported everything, and accept the override hell as the price.
  • Deep incumbent muscle memory. A team of ten Postgres experts with a decade of tooling shouldn't torch that advantage for a new paradigm. Stacks are sociotechnical; yours has to fit your people.

The Bottom Line

None of these tools is the moat. The moat is the property they share: infrastructure that arrives finished, expressed as code you own, in a form AI agents can extend. Rented-SaaS stacks give you the first. Build-everything stacks give you the second. This one is the narrow path that gives you both — which is exactly what the operating-system approach requires to be economical.

Every layer has a real cost, and I've tried to price each one honestly: a backend whose code isn't portable, a framework that keeps reinventing its cache, markup that's ugly on purpose, components you must maintain, an email vendor still earning its reputation, an auth provider you depend on. We take every one of those trades, knowingly, because what they buy is the only thing that compounds: the next feature staying cheap, forever.

An illustrative chart of the marginal cost of each new feature over a product's life: on a conventional stack, integration debt compounds and every feature costs more; on this stack, components and agent leverage compound and every feature costs less. The widening gap between the curves is the moat.

Stacks are bets on what the cost of software is about to be. This is ours.

Joshua R. Bunnell leads Product & Design at Ryse and builds bartr on the side — both on this stack. The previous post in this series, We Ditched Our CRM, covers the operating-system philosophy this stack exists to serve.

Command Palette

Search for a command to run...