Software Architecture

Inside WebOustaou's Architecture: A Monorepo Built to Split

Why WebOustaou is three Next.js apps and fifteen internal packages instead of one, how a compile-time test enforces the boundary, and one abstraction I shipped, got wrong, and deliberately un-shipped.

Jul 21, 2026

One App, Then Three

WebOustaou didn't start as a monorepo. It started as one Next.js application, and for a while that was the right call — one team, one codebase, one thing to deploy. It stopped being the right call once a one-line copy edit on the public marketing page started compiling the entire Stripe, Supabase, QR-tracking, and rich-text-editor surface along with it. The production build for a handful of marketing pages had ballooned to roughly 600MB from a couple of megabytes of actual source.

The fix wasn't a folder reorganization. It was recognizing that "marketing site" and "authenticated back-office" have fundamentally different dependency needs, deployment cadences, and blast radii — and that no amount of internal discipline holds that line without the build system enforcing it. So the app split into three:

The admin dashboard — the authenticated surface with the full dependency set

  • marketing — the public site. No Supabase client, no Stripe SDK, no auth code can compile into it, full stop.
  • admin — the authenticated back-office, carrying the full dependency set: Supabase, Stripe, QR tracking, the rich-text editor.
  • site-template — a single-tenant consumer site, meant to be forked per customer rather than shared.

The boundary isn't a convention written in a README — it's a compile-time dependency-closure test that fails the build if marketing ever imports something it shouldn't. That's the difference between an architectural decision and an architectural wish.

Multi-Tenant Without Multi-Deployment

Switching between tenant locations in the admin app

Underneath all three apps sits one Supabase project modeling a three-level hierarchy: tenants → shops → websites. A tenant is the billing and ownership entity; a shop is a physical location; a website is a public storefront, and a shop can expose more than one. Row-level security is enabled on every table, default-deny, from the very first migration — before there was any authentication system to enforce it. That ordering matters: retrofitting RLS onto an existing schema is a security re-audit; having it from the start is a design constraint you build around from day one.

Deciding Where Content Lives

One of the more interesting internal debates was where content should actually live: entirely in Supabase (everything editable, nothing static), entirely in code (fast, simple, but every marketing copy tweak needs a deploy), or split. The team settled on a tiered model: operational data — menu, hours, closures — lives in Supabase because it changes often and needs an owner-facing editor. Marketing content — hero copy, about-page text, value propositions — lives as static JSON in each customer's own repository, because it changes rarely and turning every headline edit into a CMS form would be solving a problem nobody had.

That decision wasn't theoretical — it matched how the very first customer site was already organized, which is usually a good sign a design decision is right rather than merely elegant.

Building the Wrong Abstraction, Then Fixing It

Not every decision holds up, and the more useful ones to write about are the ones that didn't. An earlier package, site-blocks, was meant to hold reusable, framework-agnostic building blocks — a header, a footer, a hero. Over time it quietly grew into owning the entire visual site: chrome, theming, interactivity, composition — while the actual per-customer template became a thin prop-forwarder around it. That's backwards. The template is supposed to own composition; packages are supposed to own primitives and data.

The fix was a deliberate reset, formalized as an architecture decision record rather than a silent refactor: site-blocks was deprecated outright, its useful pieces (a theming system, a consent-banner seam) absorbed elsewhere, and the boundary rewritten as "packages own data and primitives, the template owns the full page." A related package, tokens, was deprecated the same way after an audit found it had been published and versioned for months without the site-template ever actually importing it — the real mechanism for per-customer branding turned out to be a hand-authored brand/palette.css file per fork, not a shared design-token package at all. Deprecating a package you built and shipped is a less comfortable story than shipping a new one, but it's the more honest one.

Splitting the Data Layer by Framework, Not by Feature

The public-facing data layer went through a similar refinement. It started as a single consumer-data package doing anonymous, read-only PostgREST queries — deliberately using native fetch instead of the supabase-js client, so it stays usable from an edge function or a future cache layer without dragging in unnecessary weight. Once a customer site needed React Query integration — caching, hydration, request cancellation — that logic went into a separate consumer-query package layered on top, rather than being folded into consumer-data itself. The split means the core data-fetching contract stays importable in contexts that have never heard of React.

Paying Down Debt Before the Next Phase Needs It

The architecture decision I'm proudest of is the least visible one. Menu, hours, and closures were originally scoped to a website — the public storefront a visitor lands on. But the next phase of the roadmap is order management, and eventually a kitchen display system, and both belong to the physical shop, not to whichever public site happened to take the order. Querying kitchen state through a website-to-shop indirection would get fragile the moment a shop exposes more than one site.

So before that phase started, the data was re-parented from website_id to shop_id across the schema — a deliberate, scoped piece of technical debt paid down before it would have blocked the next feature, not during it. It's unglamorous work with no visible UI change behind it, which is usually a sign it was worth doing.

The Pattern Underneath All of This

Every one of these decisions — the app split, the RLS-from-day-one schema, the content tiering, the site-blocks reset, the data-layer split, the shop/website re-parenting — got written down as a short architecture decision record before it shipped: context, the options considered, the decision, and the consequences. Twenty-five of them exist for WebOustaou so far. None of them are long. All of them mean that six months later, "why did we do it this way" has an answer that isn't someone's memory.

That's the actual habit worth taking from aerospace systems engineering into a small SaaS product: not the tooling, but the discipline of writing the decision down while you still remember why you made it.