KeyHQ — taking a multi-tenant rental SaaS from zero to production.
Architecture decisions, real bugs, and what I would do differently if I started over tomorrow.

§01The problem
Small-to-mid landlords in India manage multiple properties across spreadsheets, WhatsApp chats, and paper receipts. They cannot track rent payments, pending maintenance, or tenant history without stitching together four or five tools. Existing SaaS is either too enterprise-heavy, or it skips Indian-market specifics like Razorpay and regional communication.
The hard part is not the CRUD — it is multi-tenancy. One owner's data must never bleed into another's, and the tenant invite flow has to be airtight while still being friendly to non-technical users.
§02System architecture
A pragmatic Next.js + Hono stack — no edge runtime, no exotic database. The whole thing is intentionally boring on the outside so the multi-tenancy logic can be careful on the inside. Shared packages live in a Turborepo alongside Schooly.
§03Three decisions worth defending
Hono RPC over oRPC
Switched to Hono's hc client for end-to-end type safety. Return types are inferred straight from the router definition — no separate type sharing, and a whole category of API contract bugs disappears.
Three-layer dashboard security
Middleware-only auth is not enough — it does not protect RSC data fetching. So: middleware (redirect), server-component layout (re-validate session), client SessionProvider (role gating). Belt + suspenders + a third belt.
Postgres over Cloudflare D1
Started on D1/SQLite for the edge convenience, then migrated. Complex multi-tenant JOINs perform better on Postgres, and the relational constraints I lean on were a real concern at scale.
§04Hardest bugs, plainly
Tenant invite blocked by middleware
Middleware was blocking unauthenticated invite-acceptance routes — new tenants could not even reach sign-up. Fix: redesign the matcher to allow public invite paths while still guarding every dashboard route.
BETTER_AUTH_URL in the wrong env section
Production URL resolution silently failed because the variable was in the server-only env section. Moving it to the client-accessible env prefix was a non-obvious fix that took a long time to track down.
Full-table scan on the tenant query
A backend audit found tenant queries loading every row and filtering in JS instead of with WHERE. Rewrote with proper Drizzle where + indexed columns. Estimated 90%+ query-time reduction.
Deploy earlier — always.
I spent weeks in a local-only environment. Real-domain deploys expose CORS, auth redirects, and env-var issues much earlier — and these are far easier to debug when you are not also debugging the feature code.
“The real lesson was not the bugs — it was that shipping early is its own kind of test, and skipping it is the most expensive form of perfectionism.”— Parm, in hindsight