All case studies
01Founder · PropTech SaaS

Makeja Homes

A multi-tenant SaaS where every property company gets its own isolated Postgres schema: leases signed online, bills generated in one click, payments reconciled automatically, and an AI assistant that knows the portfolio.

Role
Co-founder, founding engineer and product designer
When
2024 to now
Stack
Next.js 14, TypeScript, PostgreSQL (Neon), Prisma + raw SQL
Links
Live site ↗Private repo. Code walkthrough on request.
  • 4,500+
    tenants
  • 5,000+
    units managed
  • 3,000+
    digital leases
  • 80+
    client companies
01

The problem

  • Kenyan landlords run portfolios on WhatsApp groups, screenshots as payment proof, and spreadsheets with water readings done by hand.
  • Off-the-shelf tools are built for other markets: no water sub-meter billing, no KRA tax rules, no M-Pesa, no caretaker or storekeeper roles.
  • Property companies also need hard data isolation: one company's tenants can never leak into another's dashboard.
02

Architecture

EdgeNext.js middleware verifies the JWT, resolves the company slug, checks the Redis revocation list.
App407 API route handlers and 200 pages across 5 role dashboards: admin, manager, caretaker, storekeeper, tenant.
Tenant dataOne Postgres schema per company (tenant_<slug>) with 40+ tables, reached through a capped LRU of clients pinned by search_path, kept current by a per-tenant migration runner.
Shared dataA master schema for companies, billing and the public rental listings index fed from every tenant schema.
MoneyPaystack cards and M-Pesa in, a signed webhook that credits bills in one transaction, stale payments reconciled on read, integer minor units for listing prices.
IntegrationsKRA eTIMS e-invoicing, QuickBooks Online sync per client, WhatsApp reminders.
IntelligenceNjiti, an in-dashboard assistant with role-scoped live data and a distilled long-term memory per company.
03

Key decisions

01

Schema-per-tenant, not a tenant_id column

Each company's data sits in its own schema, so a missed WHERE clause cannot leak another company's tenants.

TradeoffPrisma migrations cannot reach dynamic schemas, so tenant tables are provisioned and evolved in raw SQL.
02

Provision in SQL, not prisma db push

Vercel's serverless filesystem is read-only, so the CLI cannot run at signup. A 577-line provisioner creates the schema, enums and every table directly.

TradeoffTenant schemas then drift, so a migration runner applies numbered, idempotent steps to every schema, records them in a ledger and holds a Postgres advisory lock so two deploys never migrate at once.
03

Reconcile on read instead of a cron

Tenants who abandon Paystack checkout never hit the callback, leaving payments stuck as PENDING. Stale ones are now verified with Paystack whenever anyone views payments.

TradeoffA small latency cost on that one view, in exchange for zero extra infrastructure.
04

Two-layer AI memory

Raw chat history would blow the context window within weeks. Njiti keeps the history for browsing and injects only distilled, durable company facts into prompts.

TradeoffA distillation pass has to decide what is durable, so memory is curated, not complete.
05

Write it down: DECISIONS.md

Non-obvious calls (enum sequencing in Postgres, raw SQL over prisma migrate) are logged with what was rejected and why.

TradeoffSlower in the moment, much faster for every future change.
04

Real code

lib/get-prisma.tsexcerpt
export function buildTenantUrl(schemaName: string): string {
  // Neon's pgbouncer pooler strips the `options=` parameter, so search_path
  // is never set and all queries silently hit the public schema instead.
  const direct = (process.env.DIRECT_DATABASE_URL || /* … */ '').replace('-pooler.', '.')
  // … strip any existing schema= / options= params
  return `${base}${sep}options=--search_path%3D${schemaName}`
}

export function getCachedClient(schemaName: string): PrismaClient {
  const existing = cache.get(schemaName)
  if (existing) {
    cache.delete(schemaName)   // refresh recency
    cache.set(schemaName, existing)
    return existing
  }
  // … create client, then evict the least-recently-used one past the cap
}
The pooler drops search_path, so each tenant gets a direct connection pinned to its schema, held in a capped LRU so tenant growth cannot exhaust connections.
lib/db/tenant-migrations/runner.tsexcerpt
export async function sweepAllTenantSchemas(opts: { force?: boolean } = {}) {
  const master = getMasterPrisma()
  await ensureLedger(master)
  await master.$executeRawUnsafe(`SELECT pg_advisory_lock(${ADVISORY_LOCK_KEY})`)
  try {
    const schemas = await listTenantSchemas(master)
    for (const schema of schemas) {
      results.push(await runTenantMigrations(schema, opts))
    }
    // …
  } finally {
    await master.$executeRawUnsafe(`SELECT pg_advisory_unlock(${ADVISORY_LOCK_KEY})`)
  }
}
Every tenant schema is migrated by idempotent steps recorded in a shared ledger, under an advisory lock so concurrent deploys queue instead of racing DDL.
app/api/webhooks/paystack/route.tsexcerpt
const hash = crypto
  .createHmac("sha512", process.env.PAYSTACK_SECRET_KEY!)
  .update(body)
  .digest("hex")

const hashBuf = Buffer.from(hash, "hex")
const sigBuf = signature ? Buffer.from(signature, "hex") : Buffer.alloc(0)
const signatureValid =
  hashBuf.length === sigBuf.length && crypto.timingSafeEqual(hashBuf, sigBuf)
// …
await db.$transaction(async (tx) => {
  // mark COMPLETED and credit the bill or deposit together …
})
The webhook checks Paystack's HMAC signature in constant time, then marks the payment and credits the bill inside one transaction, so money can never be half recorded.
lib/reconcile-paystack-pending.tsexcerpt
const stale = await db.$queryRawUnsafe(`
  SELECT id, COALESCE("referenceNumber", reference) AS ref
  FROM payments
  WHERE status::text = 'PENDING'
    AND "paymentMethod"::text = 'PAYSTACK'
    AND "createdAt" < NOW() - INTERVAL '2 minutes'
  LIMIT 20`)

for (const row of stale) {
  const ps = await fetch(
    `https://api.paystack.co/transaction/verify/${encodeURIComponent(row.ref)}`,
    { headers: { Authorization: `Bearer ${process.env.PAYSTACK_SECRET_KEY}` } })
  // Paystack says it never succeeded: mark FAILED. A real success is left
  // to the full verify path (bill matching, wallet credit, receipt) …
}
Abandoned checkouts are verified with Paystack the next time anyone looks, so nothing stays falsely pending.
lib/token-blocklist.tsexcerpt
const BLOCKLIST_TTL_SEC = 25 * 60 * 60 // JWT maxAge is 24h

export async function revokeToken(jti: string) {
  await kvSet(`blocklist:${jti}`, '1', BLOCKLIST_TTL_SEC)
}
export async function isRevoked(jti: string) {
  return kvHas(`blocklist:${jti}`)
}
Logout revokes the token's jti in Redis for 25 hours, one hour longer than the token can live.
05

How it was verified

  • Billing guard refuses bills or water readings for months before a tenant moved in or a unit existed.
  • Integer minor units for listing money, one rounding rule for the whole path.
  • Kenya tax module encodes MRI withholding, contractor withholding, VAT threshold, county land rates and stamp duty.
  • Paystack webhook: constant-time signature check, idempotent, money credited in one transaction.
  • Rate limiting, CSRF double-submit plus Sec-Fetch-Site checks, bcrypt, Turnstile on login and password reset.
06

Results

  • 4,500+ tenants and 5,000+ units under management.
  • 3,000+ leases signed digitally, 80+ client companies.
  • Three products on one login: MANAGE, FIND (public listings) and BUILD (verified contractor marketplace).
  • 407 API routes and 200 pages, with every tenant schema kept current by the migration runner.
07

What I'd do next

  • A direct M-Pesa STK push rail alongside Paystack.
$ ./hire --re makeja-homes

Want a build like Makeja Homes?

Senior remote roles and contract builds. Replies within a day, EAT (UTC+3).