From paper register to Firestore

React + Firebase + Claude Code

Phulwari Rent: A Family Bookkeeping App, Built by Conversation

Replacing a paper rent register with a serverless mobile-first app — built almost entirely by talking to an AI co-pilot.

A few months ago I caught myself doing arithmetic on the back of a printed electricity bill — figuring out how much rent each tenant owed for that month across a multi-unit property. Few shops, flats, a couple of rooms, and a couple of unrented halls. Each unit has a different rent, a different electricity meter, sometimes a partial cash payment, sometimes a UPI transfer to the caretaker. A pen, a calculator and a register were just barely keeping up.

I’d been meaning to build a small app for this for a while. With Claude Code in my terminal these days, “for a while” finally became “this weekend.”

This is the story of Phulwari Rent — a tiny mobile-first bookkeeping app that runs entirely on Firebase’s free tier — and what I learned about building real software through conversation rather than typing.

The Problem: A Spreadsheet Wearing a Rent Register’s Costume

Concretely, I needed:

  1. Per-unit dues that account for rent, an optional adjustment (the caretaker pays no rent — a -₹5,000 line item), and an electricity bill computed from the meter delta.
  2. Three roles — owner (full access), caretaker (records this month’s payments and any month’s meter readings), and tenant (sees only their own unit, can self-report a payment).
  3. Mobile-first because every actor in this story uses a phone, not a laptop.
  4. Shareable summaries — a one-tap “send to WhatsApp” of this month’s bill.
  5. Cheap to run — this is for one extended family, not a SaaS.

A spreadsheet would technically work. But spreadsheets don’t enforce roles, don’t compute “caretaker cash on hand to remit”, and are not pleasant to use on a thumb. So: a real app.

The Architecture That Won

I tried two architectures before landing on the third.

  • Attempt 1: Express + SQLite, the React frontend talking to a /api running on a tiny Cloud Function. Worked, but the SQLite-on-Functions story is bad — the filesystem is ephemeral, and I wanted writes to persist between cold starts.
  • Attempt 2: Express + Cloud SQL. Worked, but suddenly I was paying for a managed database and a Cloud Function for what is, fundamentally, a five-table CRUD app with three users.
  • Attempt 3 (winner): No backend at all. The browser talks directly to Firebase Auth and Cloud Firestore via the JS SDK. All access control lives in firestore.rules. The static site sits on Firebase Hosting. Total infrastructure: two Firebase services, both free at this scale.

The shape is delightfully small:

phulwari_rent/
├── client/         # React + Vite + Tailwind
├── scripts/seed.js # one-shot: creates Auth users, sets role claims
├── firestore.rules # the actual security model
└── firebase.json   # hosting + firestore + emulator config

There is no server-side code I have to deploy or monitor. The “backend” is a 117-line firestore.rules file.

The Login UX: Usernames, Not Emails

Family members are not going to type owner@phulwari.app when they sign in. They want to type owner. They especially want to type their unit id, like room-7 or shop-3, not an email address.

Firebase Auth requires emails, so I cheated. The client builds a synthetic email under the hood:


const AUTH_EMAIL_DOMAIN = 'phulwari.app';
export const usernameToEmail = (u) =>
  `${u.toLowerCase()}@${AUTH_EMAIL_DOMAIN}`;

// In LoginScreen:
await signInWithEmailAndPassword(auth, usernameToEmail(username), password);

The phulwari.app domain doesn’t even need to exist — Firebase Auth doesn’t care; it just treats the email as an opaque identifier. From the user’s perspective, it’s username + password.

The Real Security Model: Custom Claims + Rules

The actual access control sits in two places:

  1. Each Auth user has custom claimsrole (one of owner, caretaker, tenant) and, for tenants, unitId. A one-shot seed script sets these via the Firebase Admin SDK.
  2. firestore.rules reads those claims from request.auth.token and gates every read and write.

Here’s a slice that captures the spirit:

match /payments/{paymentId} {
  allow read: if isSignedIn() && canSeeUnit(resource.data.unitId);

  // Owner: anytime. Caretaker: only current month, only if unlocked.
  // Tenant: can self-report ONLY for their own unit, current month,
  // while unlocked.
  allow create: if (
    isOwner() ||
    (isCaretaker()
      && request.resource.data.monthKey == currentMonthKey()
      && !monthLocked(request.resource.data.monthKey)) ||
    (isTenant()
      && request.resource.data.unitId == tenantUnitId()
      && request.resource.data.monthKey == currentMonthKey()
      && !monthLocked(request.resource.data.monthKey))
  );
}

What I love about this model is that there’s nowhere for a bug to hide. The rules are the contract. The UI mirrors them so it can hide irrelevant buttons, but if the UI is wrong, the rules still say no.

Building It by Conversation

I’ve written a lot of small webapps over the years. This is the first one I built almost entirely by talking — Claude Code wrote the bulk of every file, and I made decisions about architecture and reviewed diffs. Some patterns that made the loop work:

  • State the desired end state, not the next step. “I want a login screen with username + password” is a better prompt than “add a TextField for username.” Claude can chain three or four steps competently if it knows the destination.
  • Make the AI prove the work. After each meaningful change I’d say “now exercise this end-to-end” — boot the API, hit the endpoint, parse the result. Half of the bugs surfaced in this verification step, not in the writing step.
  • Iterate on architecture out loud. When I realised SQLite-on-Functions wouldn’t survive a cold start, the conversation moved up a level — should this even have a backend? That kind of zoom-out happens naturally when you’re thinking aloud with a partner who can also rewrite half the codebase in five minutes.
  • Treat hallucinations as design feedback. When the AI invented a caretakerUnlocked field that didn’t exist in my model, it was actually pointing at a real problem: the caretaker-password gate had nowhere to live in the new architecture. I deleted the prompt instead of inventing the field.

The final code review I did on the app surfaced a couple of real bugs (a stale addedBy field reference, a UTC-vs-IST month boundary edge case in currentMonthKey() inside the rules, a no-op admin password prompt). All things a careful pair of human eyes would catch — none of which the AI flagged on its own. The lesson, again: the AI writes; you decide.

The Bits I Was Surprised To Care About

A few things I didn’t expect to spend time on:

  • Bundle size. The Firebase JS SDK is hefty — around 165 KB gzipped after the migration, up from 58 KB when I had a hand-rolled API. For a once-loaded mobile app this is fine, but if I were chasing performance I’d reach for firebase/firestore/lite or split the SDK into its own chunk.
  • Indexes. Firestore composite indexes are declared in firestore.indexes.json and deployed alongside the rules. Forgetting one means a query throws at runtime in production but works fine in the emulator. Found that the hard way.
  • Time zones in security rules. request.time is in UTC. India is UTC+5:30. So currentMonthKey() evaluated server-side can lag the user’s IST clock by up to six hours around the month boundary, which is exactly when caretakers are scrambling to record last-minute payments. A 330-minute offset in the rules fixed it.
  • The “no backend” tradeoff. With no server, there’s also no place to run a periodic job, no place to enforce things that need a privileged context (re-authenticating a caretaker against a separate password before allowing a settings edit, for example). For Phulwari Rent these gaps are acceptable. For something with money flowing through it, I’d add a couple of Cloud Functions back.

What I’d Build Next

In order of how much family value they’d unlock:

  1. WhatsApp deep-link share of the monthly summary, pre-formatted for one tap.
  2. Forgot-password via Firebase’s built-in email reset (the synthetic email domain makes this nontrivial — I’d switch to real-ish emails or a custom callable function).
  3. Per-tenant running balance across past months, not just the current one.
  4. PWA manifest so the app installs to the home screen and feels native.
  5. Audit log — an append-only auditLog collection so I can answer “who deleted that ₹3,300 payment?” six months from now.

Final Thoughts

The most surprising thing about this project wasn’t the AI. It was how small the app turned out to be once I committed to “no backend.” Five Firestore collections. One rules file. One seed script. A static React bundle. The whole thing fits on Firebase’s free tier and will probably stay there for years.

Building it through conversation made the architectural pivots cheap. When attempt #2 wasn’t working, I didn’t sunk-cost myself into making it work — I described the third architecture, watched the AI rewrite the layer that needed rewriting, and we kept moving. That’s the part I’m still adjusting to: the cost of changing my mind about architecture has dropped by an order of magnitude. It changes how willing I am to entertain the right shape, not just the shape I’ve already half-built.

If you’ve got a tiny app that’s been sitting in your “someday” pile, this might be its weekend. Drop into the repo and have a look — and if you’ve built something similar in this AI-pair-programming style, I’d love to hear what worked.

TECH
firebase react genai claude vibe-coding

Dialogue & Discussion