Multi-Tenant SaaS Architecture with Supabase and Prisma
Eight months into building Hodol, I almost shipped a bug that would have shown one merchant's entire customer list to another merchant. It was caught in staging, by accident, by me, on a Sunday afternoon while testing something completely unrelated. I've thought about that bug roughly once a week ever since. This post is everything I've learned about building a multi-tenant SaaS the hard way — the choices that worked, the ones I'd undo, and the very specific scars Hodol now carries on its spine.
What Hodol actually is
Quick context, because the architecture only makes sense if you understand the product. Hodol is a one-stop platform for small businesses in India — yoga studios, retreat operators, cafés, hill-station tour guides, neighborhood services. Think Shopify if Shopify also did bookings, events, payments, CRM, and shipped on a custom domain you actually own. Today there are a few hundred merchants on it, the busiest takes ~2,000 bookings a month, and the smallest sells one weekend retreat every six weeks. Same code, same database, wildly different usage shapes.
Every one of those merchants needs to feel like Hodol is theirs. Their customers should never know there's a platform underneath. That's the multi-tenancy problem in one sentence — keep the experience completely separate, while running it all on one piece of infrastructure that one person can maintain at 2am.
The first big decision: one database or many
I lost two weeks to this question. I had a whiteboard at home covered in arrows and tradeoffs. The two real options were:
- Database per tenant. Beautiful in theory — perfect isolation, easy to reason about, easy to delete one tenant's data without touching anyone else's. Disastrous in practice for an early-stage product. Every migration runs N times. Connection pools fragment. Backups multiply. Cost climbs linearly with tenant count, regardless of whether the tenant is paying you or sleeping on the free tier.
- Shared database, row-level scoping. One Postgres database, one schema, one
tenantIdcolumn on every tenant-scoped table. Cheap, fast, simple to migrate. The tradeoff: a single bug anywhere in the data layer can cross-contaminate tenants. The blast radius is the entire customer base.
I went with the shared database. The deciding factor wasn't cost — it was that I'd be operating this thing alone, sometimes from a café in Manali, and I needed an architecture I could understand at a glance after a long day of trekking. One database is one mental model. N databases is N. But going shared meant I had to take isolation more seriously than most teams ever do.
The schema, in one sentence
Every tenant-scoped Prisma model has a tenantId String field, indexed, with a foreign key to the Tenant table, and a composite unique constraint that includes tenantId wherever uniqueness was previously single-column. So a product slug is unique within a tenant, not globally. A staff email is unique within a tenant. Without that detail, two merchants can't both have a product called “weekend-retreat”, which is a great way to lose customers.
I missed two of those composite constraints in my original schema. Found them when a merchant onboarded and got a duplicate-key error trying to create a category they'd definitely never created. That was a panicked Friday evening migration. Lesson learned: when you do the “add tenantId everywhere” pass, also do a “rewrite every unique constraint” pass on the same day, in the same PR.
The middleware chain (and the bug that almost shipped)
Every request to the Hodol API passes through three middlewares, in order:
- Tenant resolution. Pulls the tenant out of the request — from a subdomain (
studio.hodol.in), a custom domain (bookings.thatonecafe.com), or an explicit header on the dashboard API. The result is cached in Redis with a 5-minute TTL, so 99% of requests skip the database lookup entirely. - Authentication and authorization. Validates the Supabase JWT, loads the user, and — critically — verifies that the user actually belongs to the tenant resolved in step one. This was the bug. My early code resolved the tenant from the subdomain, then loaded the user's primary tenant from the JWT, and didn't check that they matched. A merchant with two storefronts could, by manipulating headers, see data from the wrong one. I caught it because I was QA-ing on my own staging account that had two tenants and the data looked weirdly familiar.
- Tenant-scoped Prisma client. The request context now carries a verified
tenantId. I attach a Prisma client extension that automatically injectswhere: { tenantId }into every find/update/delete on tenant-scoped models. Forget to scope manually, and the extension scopes for you. This is the most important 80 lines of code in the entire system.
On top of that, I added Postgres Row Level Security policies retroactively. RLS is the database's own “you cannot see this row” layer, completely independent of my application code. If my middleware ever has a bug, RLS is the parachute that prevents catastrophe. I should have built it from day one. I'll come back to that.
Payments: where the real complexity lives
Most multi-tenant tutorials end at “here's how you scope queries.” That's when the actual hard work begins. Hodol does not touch merchant money. Each merchant connects their own Razorpay (or Stripe, for international) account, and customer payments flow directly into the merchant's bank — not through ours. This is non-negotiable for two reasons: it's the right thing for merchants (their money, their account, no platform settlement risk), and it keeps WDA out of the licensed-payment-aggregator business, which I am very much not equipped to be in solo.
That means every tenant has their own set of API keys, webhook secrets, and gateway preferences. Storing those in plaintext in the database would be malpractice. So they're encrypted at rest with AES-256-GCM, using a master key in the environment, with a per-tenant data-encryption key. Decryption only happens at the moment of use, in memory, and the decrypted value never gets logged, never gets sent to an error tracker, never lands in a Sentry breadcrumb. I have explicit redaction rules in my logger for fields that even look like they could be a secret.
Webhooks are the other beast. Razorpay and Stripe both want a single URL to send events to, but my events belong to specific tenants. So I namespace the URLs: /api/webhooks/razorpay/:tenantSlug. When a webhook arrives, the middleware resolves the tenant from the slug, fetches and decrypts that tenant's webhook signing secret, validates the signature, and only then — only then — do we process the event. A signature mismatch returns 400 and gets alerted, because in production it usually means either a misconfigured tenant or someone probing the endpoint.
That 2am Razorpay debugging session I mentioned in the other post? It was a merchant who had rotated their webhook secret in the Razorpay dashboard but not in Hodol. The bug was in the world, not the code. I added a tiny “send a test webhook” button in the merchant settings page the next morning. Five minutes of work that has saved approximately fourteen subsequent late nights.
The Turborepo monorepo
Hodol is one repository with three deployable apps and a shared packages/ folder for the things they all need:
- apps/api — the brain. Express, TypeScript, Prisma, Supabase Auth on the JWT side, Razorpay and Stripe SDKs, BullMQ for background jobs (notifications, reminders, settlement reconciliation). This is where all the actual product logic lives.
- apps/dashboard — the merchant admin. Next.js, server components where they make sense, client components where interactivity is unavoidable. Products, bookings, events, orders, customers, staff, analytics, settings, payouts.
- apps/storefront — what the customers see. Next.js, ISR with on-demand revalidation when merchants edit content, fully responsive, Indian-network friendly (image optimisation, lazy fonts, deferred analytics). The same codebase serves all merchants — the styling, branding, and configuration come from the database based on the resolved tenant.
- packages/types, packages/zod-schemas, packages/utils — shared TypeScript types generated from Prisma, Zod schemas for validation that runs identically on the API and the frontends, and small utility functions. If you've ever had a bug because the frontend and backend had subtly different validators, you understand why this matters.
Turborepo orchestrates builds and caches them aggressively. A change to the API rebuilds everything that depends on the shared types. A change to a single dashboard component rebuilds only the dashboard. CI goes from a 9-minute pipeline to under 2 minutes when the cache is warm. As a solo developer, those minutes are not abstract — they're minutes of my actual life I get back per push.
Custom domains, and why they were harder than I expected
Every Hodol merchant starts on a subdomain: your-name.hodol.in. When they're ready, they can attach a custom domain they own. This sounds like a settings-page feature. It is, in fact, three weeks of fiddly DNS work I'll never get back.
The flow: merchant adds bookings.theirbusiness.com in settings. We give them a CNAME target pointing to our edge. They update DNS at their registrar (this is the step where 30% of merchants get stuck — GoDaddy's UI alone has cost me hours of support chat). We poll DNS until propagation completes. Cloudflare for SaaS issues an SSL certificate. We store the mapping in Postgres, cache it in Redis, and the storefront resolves tenant by hostname on every incoming request.
The thing tutorials don't mention: edge cases. What if a merchant tries to add a domain that's already attached to someone else's tenant? What if they remove DNS without disconnecting in our app, and then someone else attaches the same domain? What if their certificate fails to renew at 3am? Each of these became real, each got handled, each one is a tiny piece of code that makes the system feel solid instead of brittle.
What I'd do differently if I started today
RLS from day one. Postgres Row Level Security is the seatbelt of multi-tenant systems. Wear it always. Adding it retroactively meant auditing every query, every join, every raw SQL escape hatch I'd written. If I'd started with RLS, the audit would have been continuous and free. Instead it was a two-week deep-clean.
Treat tenant context as a first-class type, not a string. Early on I passed tenantId: string around. Easy to forget. Easy to swap with another string by accident. Now I have a branded type — type TenantId = string & { __brand: 'TenantId' } — that the type system refuses to confuse with a UserId or a ProductId. It looks like overkill until the first time it catches a real bug.
A “view as tenant” admin tool from week one. I waited too long to build the equivalent of Stripe's “view as merchant” mode. Now I have a superadmin route that lets me impersonate any tenant in read-only mode, with a giant red banner reminding me I'm impersonating. Most production bugs get reproduced and diagnosed in under five minutes because of it. Without it, support was guesswork and screenshots.
Redis from day one, not week six. I started with in-memory caching and told myself I'd “upgrade later.” Later came as soon as I added a second API instance, at which point caches diverged and I had ten minutes of confused users. Just put Redis in from the start. It's cheap. It's small. It saves you from yourself.
Audit logs as a built-in primitive. Not an afterthought, not a feature, not a future ticket. From the first migration. Every write that touches money, identity, or permissions writes an audit row. When (not if) something goes wrong with a merchant's data, the difference between “I can tell you exactly what happened” and “let me investigate” is the difference between a five-minute conversation and a panicked Sunday.
The unromantic truth about multi-tenancy
Multi-tenant SaaS is not a clever architectural trick. It's a discipline. The same handful of decisions — scope every query, encrypt every secret, audit every write, never trust the client, design for blast radius — applied with absolute consistency across every endpoint, every migration, every PR, for as long as the product exists. Get any one of them wrong, even once, and the failure is loud and public.
The architecture I've described is not exotic. It's Postgres, Express, Prisma, Supabase, Redis, Razorpay, Cloudflare. Boring, well-understood pieces. The whole point is that the boring stack lets all the energy go into the parts that actually differentiate Hodol — the merchant onboarding, the booking UX, the conversation a café owner has with their customer through our platform without ever knowing we exist.
Eight months in, that one bug from staging still keeps me honest. Every PR I open, I read it twice with the question “could this leak data across tenants?” in my head. So far, the answer has stayed no. Boring, careful, correct — in that order. That's the job.
Building a multi-tenant SaaS? Let's talk on WhatsApp or check out the full Hodol case study.