Back to Blog

Building a SaaS from Scratch: Lessons from TendMgmt

Building a SaaS from Scratch: Lessons from TendMgmt

TendMgmt is our most complex build to date — a multi-tenant SaaS platform for property management companies, HOA communities, and independent landlords. Building it taught us a lot about making technical decisions under time constraints without backing yourself into corners.

The Architecture Decision That Mattered Most

We went with a shared database, row-level security (RLS) approach rather than schema-per-tenant or database-per-tenant. The reasoning: a single PostgreSQL instance with RLS lets us isolate every tenant's data at the database level without the operational overhead of managing hundreds of separate schemas or databases.

The tradeoff is that RLS must be applied consistently and correctly to every table. Miss a table and you've exposed tenant data. We wrote all RLS policies in a single migration file (00009_rls_policies.sql) so they could be audited in one place. For a small team, this approach is dramatically more maintainable than the alternatives.

Supabase Made the Auth Layer Tractable

Building multi-role auth from scratch is a week of work minimum. We used Supabase Auth with a custom JWT hook to inject claims at login time — organization ID, role array, property IDs. Every server component and RLS policy can then read these claims without a round-trip to the database.

The custom JWT approach means authorization logic lives in the token, not in the application layer. Pages render faster. The tradeoff is that role changes don't propagate until the next login — acceptable for our use case, and explicitly documented.

Seven User Roles Is Two Too Many

TendMgmt has seven distinct user roles: Platform Admin, Org Admin, Property Manager, Tenant, HOA Board Member, Maintenance Worker, Property Owner. In retrospect, we should have started with four (Manager, Tenant, HOA, Owner) and added the others only when customers explicitly needed them.

The complexity of seven roles is manageable in code — each has its own route group, middleware guard, and set of database permissions. But it's a lot of onboarding surface area. The next platform we build will launch with the minimum viable role count.

The Stripe Connect + Stripe Billing Split

We use Stripe in two completely different modes:

  • Stripe Connect (platform mode) for rent collection: tenants pay through a payment element, funds route through Connecticode to the org's connected bank account
  • Stripe Billing for SaaS subscriptions: orgs pay Connecticode a monthly per-unit fee

These are different products with different APIs, different webhooks, and different test modes. Keeping them clearly separated in the codebase (two different webhook endpoints, two different helper files) prevented the confusion of treating them as one thing.

Don't Underestimate the Webhook Surface Area

Stripe sends webhooks for subscription.created, subscription.updated, subscription.deleted, invoice.paid, invoice.payment_failed, customer.subscription.deleted, payment_intent.succeeded, payment_intent.payment_failed... and that's just Billing. Connect adds account.updated, transfer events, and dispute webhooks.

Every webhook that arrives needs to be handled idempotently — if the same event arrives twice, the second handling shouldn't cause a problem. We wrote explicit idempotency checks for every event type we handle. This is unglamorous work but prevents subtle double-billing or double-credit bugs.

What We'd Do Differently

If we were starting over, we'd build the tenant portal first and the manager portal second. Tenant activation (invite → accept → pay → access) is the revenue-generating sequence. We built the manager portal first because it felt more "core" — but the tenant portal is what actually gets paid.

We'd also budget 20% more time for email and notification flows. Sending a lease activation email sounds like a small task. In practice it requires template design, Resend domain verification, sender reputation warm-up, unsubscribe handling, and bounce rate monitoring. None of that is hard, but it all takes time that isn't obvious in a typical sprint estimate.

The Stack, If You're Curious

  • Framework: Next.js 15 (App Router) + TypeScript
  • UI: shadcn/ui + Tailwind CSS v4
  • Database: Supabase (PostgreSQL + RLS)
  • Auth: Supabase Auth + custom JWT claims
  • Payments: Stripe Connect + Stripe Billing
  • Email: Resend
  • Hosting: Vercel

It's a modern, well-documented stack. Every piece has a large community and good documentation. The only "exotic" choice is using Supabase RLS as the primary security layer, which requires learning PostgreSQL RLS syntax — but it pays back that learning cost in simplicity at the application layer.