Note · Architecture

I built the same app three times, on purpose

~6 min read · React · NestJS · Flutter · July 2026
Three glass 3D invoice documents standing side by side

On my GitHub there are three invoicing apps: Pizza Box System (React + Express + Prisma), InvoiceMe (Flutter, offline-first), and Asset Vault (React 19 + NestJS 11 + tRPC). That's not indecision — it's the most useful learning exercise I've ever run. Same problem, three stacks, and the differences taught me more than any of the three apps alone.

Why rebuild instead of extend?

Tutorials teach you a stack on a toy problem. Rebuilding inverts that: the problem stays fixed — clients, invoices, line items, stock, totals that must never lie — and the stack becomes the variable. When you already know exactly what the app must do, every hour goes into understanding how this stack wants you to do it. There's no "is this feature right?" noise; there's only "what does this architecture make easy, and what does it punish?"

Build one: the workhorse

Pizza Box System — React + Express + Prisma. The default full-stack recipe, and it earns its popularity: Prisma makes the schema the source of truth, Express stays out of the way, and you ship fast. Its weakness showed at the seam — the API boundary is stringly-typed. The server can change a response shape and the client won't know until something renders undefined. I patched that with discipline (shared types, careful reviews), but discipline is exactly what a better stack automates.

Build two: the phone in the apron pocket

InvoiceMe — Flutter, offline-first. Mobile flips the physics. A phone in a delivery van cannot assume a connection, so "the database" stops being a place and becomes a conversation: local writes first, sync when you can, reconcile when you must. Building invoicing around that constraint forced me to think about state the way distributed systems do — what's authoritative, what's pending, what happens when two edits collide. The UI layer (Flutter's widget tree) was the easy part; the sync model was the app.

Build three: the type-safe flagship

Asset Vault — React 19 + NestJS 11 + tRPC + PostgreSQL. This one exists specifically to kill the seam problem from build one: with tRPC, the client imports the server's types, so a changed response shape is a compile error, not a production surprise. Then the "real product" weight got added — Stripe subscription billing, Puppeteer-rendered PDF invoices, multi-store inventory with transfers, offline caching via IndexedDB, and 50+ schema migrations of accumulated evolution. It's the version where the lessons of the other two are structural, not aspirational: the case study has the details.

What survived all three rebuilds

  • The domain model barely changed. Clients, invoices, line items, stock — the entities and their rules moved across stacks almost untouched. That's the part worth designing carefully, because it's the part you keep.
  • Every stack's "hard part" is somewhere else. Express made the API boundary risky; Flutter made state synchronization the boss fight; the tRPC stack made setup heavier but the seams safe. There is no stack where everything is easy — there's only choosing which hard part you want.
  • Totals are sacred. In all three, the code I tested hardest was the same: money math and stock counts. The framework never saves you there — an invoice that's wrong by a cent is wrong in every stack.
  • The second rebuild is where the learning spikes. The first rebuild feels like translation. By the third, you're not learning syntax anymore — you're comparing philosophies, and that's the understanding that transfers to any stack you'll ever pick up.

The takeaway

If you want to actually understand your tools, don't build three apps in one stack — build one app in three. It's the difference between having used a framework and having compared it. And when someone asks "why tRPC?" or "why offline-first?", the answer isn't a blog post I read — it's a repo where I paid the price of the alternative.

Read nextBuilding a terminal packet sniffer