IvánDíazLab

Each agency sees its own data.

Approved agencies quote, book and pay. Each sees its own data.

Concept illustration: Multi-tenant B2B platform.
Multi-tenant B2B platformConcept illustration

Next.js 15 (App Router) · Strict TypeScript · Tailwind 4 · shadcn/ui · Prisma · PostgreSQL 16 with RLS · Better Auth + 2FA · Redis · Resend · Vitest + Playwright

Built. And verified.

RLS
isolation enforced by PostgreSQL, not by the app
0
monetary amounts in floating point: everything in cents
1 role
allowed to bypass RLS, and only for authentication
200 → ∞
silent list truncation became cursor pagination
How it was built.Decisions, evidence and details

Project facts

Client
Private company (anonymous). Curated B2B network of travel experiences.
Timeline
July – August 2026
Status
In production
Role
Architecture, build and security rules
Model
Approved agencies quote, book and pay; not an open marketplace

Context

A private company runs a curated network of travel experiences: pre-approved travel agencies quote, book and pay for activities operated by third parties. Not anyone can sign up and not anyone sees everything; that filter is precisely the product.

That means tenants who must not see each other live in the same database, alongside suppliers whose commercial data must never reach the end customer even though it appears in the same booking flow.

The system also handles money: quotes, bookings and payments. Any rounding or visibility error is not a cosmetic bug, it is a commercial dispute.

The problem

In multi-tenant systems the question is not whether code will leak data between tenants, but when.

Trusting isolation to filters written into every query works until someone writes a new query at seven on a Friday evening. No bad faith is required: forgetting one tenant filter is enough, and at that moment one tenant sees another's business.

The same risk shows up in documents. A voucher is assembled from booking data, and the booking contains the supplier. If the document is generated «with whatever is there», the supplier's information reaches the end customer without anyone deciding it.

And there was a concrete debt from the first version: lists were silently truncated at 200 rows. Anyone querying a busier period got an answer that looked complete and was not, which is the worst way to be wrong.

Key decisions

Each of these rules is written as non-negotiable inside the repository itself, so it outlives whoever wrote it.

Isolation is enforced in the database

PostgreSQL has Row Level Security enabled: the engine decides which rows each tenant can see, not the application layer.

Because a rule living in code depends on every query remembering it, and one is enough to break it. If it lives in the database, the forgetful query simply gets no foreign data.

One exception, tightly scoped

There is a PostgreSQL role allowed to bypass RLS, and its use is restricted exclusively to the authentication adapter.

Every real security rule needs a door for the process that validates identities. What matters is that the door is one, is named, and is good for nothing else; a fuzzy exception ends up being used «just this once» across half the app.

Money is an integer

All amounts are stored and computed in cents, never in floating point.

Floating-point numbers do not represent monetary amounts exactly and accumulate differences when summed. In a system where the customer compares their total against the supplier's, that one-cent difference ends in a phone call.

The voucher does not leak, proven by a test

An automated test verifies that the document the customer receives never includes supplier data. Sensitive fields are also encrypted at field level.

It is the same logic as the rest of the system: a rule that only exists in the team's head disappears with the team. Written as a test, any future change that breaks it is stopped before it reaches production.

Cursor pagination, mandatory

The second version removed the silent 200-row truncation and requires cursor pagination on listings.

An incomplete list presented as complete makes someone take decisions on half the data without knowing it. I would rather force pagination — more work for the API consumer — than return a comfortable lie.

Where each guarantee lives

Every rule sits in the deepest layer possible. The lower a guarantee lives, the fewer ways there are to bypass it by accident.

  1. Row Level Security

    PostgreSQL decides which rows each tenant can see. A badly written query cannot see more.

  2. A single privileged role

    One role can bypass RLS, and it is restricted to the authentication adapter.

  3. Money in cents

    Never floating point: amounts are integers and do not accumulate rounding error.

  4. The voucher never leaks the supplier

    An automated test verifies the customer's document never shows supplier data.

  5. Cursor pagination required

    Replaces the silent 200-row truncation of the first version.

The solution

The application is Next.js 15 with strict TypeScript over PostgreSQL, with its own authentication supporting a second factor, Redis caching and transactional email for confirmations. Booking documents are generated from the app itself.

The interesting part is not the framework, it is where each guarantee sits: isolation in the database engine, money as integers in the domain model, supplier confidentiality in an automated test, and list integrity in the API contract.

Tests run at two levels: unit tests for domain rules and end-to-end tests for the complete quote, booking and payment flows.

Results

  • In production, with approved agencies operating over tenants isolated by the database engine itself.
  • Non-negotiable rules, documented and auditable: cents, field-level encryption, a single role able to bypass RLS.
  • An automated test preventing the customer's voucher from showing supplier data.
  • Silent 200-row list truncation replaced by mandatory cursor pagination.

What I learned

That multi-tenant security is won or lost in the deepest layer. Everything solved in the database stops depending on the discipline of whoever writes the next query.

That writing rules as «non-negotiable» inside the repository is not bureaucracy: it is what keeps them alive when the project changes hands.

And that an incomplete answer pretending to be complete is more dangerous than an error. An error is visible; a truncated list is believed.

Your operation can take the next step.

Let’s talkMore projects