Kuga
Back

Elite Varan

Solo-built multi-tenant matrimony SaaS — schema-per-tenant Postgres, 100+ API routes, in production.

May 5, 2026

Problem

A niche segment of the matrimony market needed a multi-tenant platform that could host independent brands under one engine — each with their own domain, branding, and pricing — without any chance of one tenant's data leaking into another. Off-the-shelf tools didn't cover the domain-specific matching (kundali horoscope compatibility, KYC review), and the existing custom builds came in at quotes I couldn't justify funding.

Constraints

Solo build, part-time alongside a day job, no funding. ~2 months of active feature development before launch. Hard requirement: live in production with real users — no staging-only "portfolio project" energy.

What I built

A Next.js 15 (App Router) platform with two surfaces — a public matrimony app (port 3007) and a tenant-aware admin (port 3008) — sharing a PostgreSQL database with strict schema-per-tenant isolation. 100+ API routes organised behind a composable middleware chain (auth → tenant → approval → [admin] → rate-limit) and a repository + service factory pattern with type-safe context propagation. A lazy kundali compatibility engine on top of `astronomy-engine`, with a cache layer so repeat compatibility checks don't recompute. KYC document review flow with admin approvals. Field-level encryption for sensitive data. Better Auth on Drizzle adapter with bcrypt + jose token verification. End-to-end tests in Playwright, unit tests in Vitest, load tests up to 500 concurrent.

Key decision

**Schema-per-tenant Postgres, not shared-schema with a tenant_id column.** Each tenant gets its own PostgreSQL schema; queries route through the schema via `search_path=tenant_{slug},public`. New tenants are provisioned by cloning a `template_tenant` schema. The cost is real — per-schema migrations, more complex provisioning, more careful observability — but the upside is that tenant isolation is **ironclad by construction**: there is literally no SQL path that lets one tenant read another's data, because the data isn't even reachable from the current `search_path`. For a matrimony platform where leaking a profile across tenants would be a credibility-ending event, that guarantee is worth the operational tax.

Outcome

Live in production at elitevaran.com. 100+ API routes across public, admin, cron, and internal surfaces. 15 repositories and 16 services composed through a single tenant-services factory. Schema-per-tenant isolation verified with real tenants on the platform. The kundali matching engine ships horoscope-aware compatibility — domain-specific logic running on top of real astronomical computations.

Hindsight

Two real reflections from solo-building this. First: schema-per-tenant has compounding operational cost as tenant count grows — every schema change is a migration that has to run across every tenant schema. I built a `postbuild` hook to apply migrations automatically, and it works, but I would invest in better tenant-aware observability earlier next time. The first time a migration silently failed on one tenant out of N, I noticed it from a 500 error, not from the deploy. Second: I split public and admin into two Next.js apps from day one, on the assumption that admin would scale differently. In hindsight I'd reconsider that — the duplicated dependencies (~80% overlap), shared auth wiring, and parallel deploys cost more than the deploy-isolation payoff was worth at this stage. One Next.js app with role-based routing and a separate admin entrypoint would have been the lighter call.

Architecture notes

Three pieces carry most of the weight.

Schema-per-tenant multi-tenancy

The shape that makes the rest of the system simple. Each tenant lives in its own PostgreSQL schema (tenant_{slug}), and a public schema holds genuinely shared data (users, packages, master data). The connection is opened with options=-csearch_path=tenant_{slug},public, so Drizzle generates unqualified SQL like SELECT * FROM profiles and Postgres resolves it to the right schema automatically.

Tenant tables have no tenantId column. The schema is the isolation. That's the property worth paying for: there is no application-layer query that can accidentally join across tenants, because the only data the connection can see is the data its search_path includes.

Composable middleware chain

Every protected route runs through a composed pipeline:

withAuth → withTenant → withApproval → [withAdmin] → withRateLimit → handler

Each layer narrows the type of the context it passes:

AuthContext     → { user: { id, email, profileId, tenantSlug } }
TenantContext   → AuthContext + { tenantSlug, tenantId, db, services }
ApprovedContext → TenantContext + { user.profileId guaranteed non-null }
AdminContext    → ApprovedContext + admin permissions

Route handlers receive the context type they actually need, and the type system rejects code paths that use, say, services before the tenant middleware has run. The factory pattern below makes this ergonomic.

Repository + service factory pattern

Data access (repositories) and business logic (services) are both factory functions, wired together once per tenant DB:

export function createTenantServices(db: TenantDb) {
  const profileRepo = createProfileRepository(db);
  const kundaliRepo = createKundaliMatchRepository(db);
  const profileService = createProfileService({ profileRepo });
  const kundaliService = createKundaliService({ kundaliRepo, profileRepo });
  return {
    profile: profileService,
    kundali: kundaliService,
    repos: { profile: profileRepo, kundali: kundaliRepo },
  };
}

Route handlers reach for context.services.kundali.compute(...) rather than constructing services themselves. Tests inject mock repos through the same factory shape. No DI container, no decorators — just functions that return functions.

The kundali engine

The actual differentiator. It takes two sets of birth details, computes both horoscope charts on top of the astronomy-engine library, runs domain-specific compatibility scoring (guna milan, dosha matching), and returns a categorised match result. The service is lazy and cached — results are stored in a kundali_match_cache keyed by the pair of profile IDs, and only recomputed when the underlying horoscope data changes. That keeps Smart Match and the per-pair compatibility view fast even at scale.

Why this composition

The schema-per-tenant boundary, the typed middleware chain, and the factory wiring mean that adding a new feature usually involves a new repository, a new service, and one route — and the type system tells me when I'm wiring something incorrectly. Solo shipping with this much surface area only works if the framework around the features is doing its job. This one did.

Tech stack

Next.js 15 (App Router)PostgreSQL (Railway)Better Authastronomy-engineTypeScriptDrizzle ORMUpstash Redis / QStashCloudflare R2ResendPostHogVitestPlaywrightpnpm workspacesVercel