PadiRent

Rent and tenancy management for Ghanaian landlords, built offline-first.

A phone and a rent book on a table.

PadiRent tracks properties, units, tenants, payments and rent expiry dates for landlords and agents in Ghana. Web app and iOS app. It is at padirent.com.

This page is about how it was assembled.

The constraint

Start with the account. A landlord in Kumasi has a phone number and may not have an email address they check. So the identity primitive is the phone number, and the login is an SMS one-time code delivered through Arkesel. That decision propagates: there is no password to reset, no email verification step, and every later feature that wants to reach a user reaches them by SMS or push rather than by inbox.

Then the network. Data is intermittent enough that a slow page is a lost session, and expensive enough that a heavy bundle is a real cost to the user.

Then the review cycle. Once an app is on the App Store, a one-line fix takes days to reach a user unless you have built a path that does not go through Apple.

The stack, and why

Next.js 16 with the App Router, Prisma 7 against Postgres via the pg adapter, NextAuth v5 with a credentials provider for phone and OTP, Tailwind v4 and Radix on the web. The mobile app is Expo with expo-router, React Query and expo-secure-store. Hosting is Railway with git-connected deploys.

Prisma 7 moved the datasource URL out of the schema into prisma.config.ts, and Next 16 made route params a promise you have to await. Neither is interesting except that both are the kind of upgrade that quietly breaks a route handler at runtime rather than at build time, so the migration was slower than the changelog suggested.

two client boxes at left, `Browser (session cookie)` and `Expo app (bearer JWT)`, both arrowed into a single rounded box labelled `getAuthUserFromRequest()`, which feeds `API routes` and then a Postgres cylinder.

Three decisions

Dual-mode authentication on every API route. A request can carry a NextAuth session cookie from the browser or a bearer JWT minted for the mobile app. getAuthUserFromRequest() resolves either. The alternative was a separate mobile API surface, which would have doubled the routes and guaranteed drift between them. The cost of the shared approach is that any route added without going through that helper is a silent authorisation hole, so it is the one pattern we enforce on review.

Over-the-air updates, with a hard line. The Expo app checks for a JavaScript update on cold launch and reloads it during the splash, and a GitHub Action publishes on merge. So a copy fix or a layout bug reaches installed phones the same day. The line is native modules: adding one means the shipped binary does not contain it, and pushing that JavaScript to the old runtime crashes the app at import. Version bumps and native dependency changes are gated to a full build. That rule is written down because it is easy to forget on a Friday.

Push and SMS both, not push instead of SMS. Expo push tokens are stored per device and pruned on DeviceNotRegistered. The rent-expiry cron pushes the landlord and also sends the existing SMS. Push only reaches people on a recent build who granted permission, and SMS costs money on every send, so running both is the deliberately wasteful option. It is also the one where the landlord actually finds out.

What broke

Expo Go pulls JavaScript from Metro on the developer's machine. The API base URL in app.json is hardcoded to production, and nothing overrides it.

So during a preview the interface updates instantly on the phone while any API or schema change stays invisible. A rent field rendered a literal NaN on the device: rentAmount had been added to the vacant-units endpoint locally and never deployed, so String(Number(undefined)) did what it says. Two bugs came from that split before we named it.

The habit that fixed it is boring. Before debugging any "the data is wrong" report from a phone preview, check whether the API change actually shipped. And when a change spans schema, API and client, deploy in that order. Reverse it and the app queries a column that does not exist.

One more pass, measured

The August 2026 overhaul was mostly subtraction. The admin console lost fourteen gradients, twelve ring halos, ten stray accent colours and five hover-lift effects, and five landlord pages moved from client components with effects to server components with streaming skeletons.

The part worth reporting is the contrast work, because we measured it rather than eyeballing it. Input borders went from 1.33:1 to 3.58:1 across the whole application. Disabled buttons went from roughly 2.27:1 to explicit colours. A warning colour in the mobile app was being used as text at 2.15:1, so it was split into separate fill and foreground values. The first pass fixed only the admin screens and left the customer-facing forms broken, which is the usual shape of an accessibility fix done by whoever is looking at the screen that day.

Where it is now

Live on the web and on iOS, with the emerald Padi Key brand applied across both after the June 2026 rebrand. There is a batch of tenant fields committed and inert behind an unshipped migration, which is a normal state for a small team and an uncomfortable one to leave sitting.

One product decision is worth recording because it changes the schema. Every tenant a landlord enters is a person who did not sign up. Their details were typed in by someone else, so a Ghana Data Protection Act lawful basis for marketing to them does not exist. Contact fields on a tenant stay optional, and anything sent to that number stays transactional.

Built by BestDid

We built PadiRent and we run it. Pricing, features and sign-up are at padirent.com.

Related builds: the FixAm case study covers a two-sided marketplace with phone-verified reviews, and the Vendwa case study covers an agent on the WhatsApp Cloud API. The rest is on our work page. For a build conversation, see services.

Visit PadiRent