Most consumer apps optimise one user at a time. The signup is single-player, the home screen is personal, the retention curve is computed per account. That model works because for most products the user is the customer, the user is the consumer, the user is the locus of value.
Couples apps break this. The unit of value is two people who have to coordinate, not one person who has to convert. Every decision you make about onboarding, retention, pricing, and notifications shifts under your feet once you accept that.
EqualHome is a couples app that makes household labor (tasks plus mental load) visible and fair. Web (live), iOS and Android (currently in App Store and Google Play review). Solo build, three surfaces, a backend in production on Google Cloud Run. 16 commits, 28 REST endpoints, 5 cron jobs, 0 TypeScript errors at every CI gate.
This article is not the shipping log. It is the post-mortem on the design and product decisions that took roughly three months from idea to App Store submission, with a focus on the ones that only make sense once you accept the multi-user unit.
The product question that justifies the work
Couples fight about housework. They fight more about the housework one of them does not see. Mental load (remembering doctor appointments, knowing what is in the fridge, deciding what is for dinner) is invisible labor: the hardest to recognise, the hardest to share. There are productivity apps for individuals, shared calendars for families, chore wheels for kids, but nothing that takes the equity question seriously for the two adults who actually run a household.
That is the gap.
The product hypothesis: if a couple can see the imbalance, and if the system gives both of them a way to agree on what to rebalance, the conversation shifts from blame to repair. The hypothesis is not that we will solve household equity. It is that we will create a shared object both partners can point at, which is currently missing from the household.
Decision 1. Anonymous-first onboarding
Most apps with a couple or household model gate the experience behind a signup wall and a partner invite before anyone sees value. EqualHome inverts this.
You can explore the entire app in anonymous mode. Firebase anonymous auth gives you a UID. You can browse the categories, see the Balance Score, log a few tasks. The “save your account” prompt comes after you have used the product for at least one cycle, and when it does come, it preserves the UID through Firebase’s linkWithCredential. No data loss on conversion.
Why this matters in numbers I can defend internally: in couples apps, asking for both a personal account AND a partner invitation before any value is shown roughly halves activation. Anonymous-first removes both walls and lets the product earn the signup.
The interesting downstream consequence: anonymous users still receive push notifications, still see the daily prompt, still get suggestions. The product behaves like a real product even when there is no profile attached yet. That decision flows from the product thesis (the value is in the daily logging, not in the account), but it has architectural consequences across every endpoint.
Backend work this required: a separate auth middleware, verifyTokenOnly, that validates the Firebase ID token without requiring a Firestore profile to exist (because the create-profile route cannot require a profile). The original middleware, requireAuth, was too strict and blocked exactly the route it should not have blocked. One of those bugs that is obvious only once you have written the second middleware.
Decision 2. The two-threshold invitation
When the unit of value is a couple, only half the people are sitting in front of the app at any given moment. The other half is being invited, often through a channel the inviter chose instinctively, often through a phone they are looking at while in the same room as the inviter.
EqualHome’s invitation has to clear two thresholds the inviter rarely thinks about. It has to look serious enough that the partner takes it seriously. It also has to be short enough to share over the channel real couples actually use. In our target geography that channel is WhatsApp, followed by SMS.
Three primitives:
- A long, secure token (64 characters) used in deep links. Cryptographic, unguessable, single-use.
- A short code (8 characters) used in the URL,
equalhome.app/j/XKFM2Q9P. SMS-friendly, copy-pasteable, easy to type if the link gets mangled by a messaging app. - A dedicated landing page for the invited partner, with the partner’s name pre-filled in the welcome copy.
The 64-character token is technically superior. It is also socially impossible to forward over SMS. The short code exists because of that one fact. When the flow has two real thresholds (technical and social), engineering one and ignoring the other ships a worse product than engineering both at 80%.
Decision 3. The Balance Score, and the vote that follows
The product’s central artifact is a Balance Score. Two circles, one per partner, breathing in counter-phase animation on the home screen, with the percentage split underneath. The visual itself is the message: this is what the system sees, can you both look at it together.
But a score is not a feature. A score is an invitation to do something about it.
The follow-up is the weekly insight. Every Sunday, an LLM (Claude Sonnet on the backend) generates a one-paragraph read of the past week’s data with one proposed action, always framed positively (“partner X could take on Y”), never blame-framed (“partner X is not doing enough”). Critical guardrail: the prompt is sanitised against injection (real users with real names write hostile-looking things sometimes), and the LLM’s response is validated against a Zod schema before it reaches the app. The model is not allowed to return arbitrary shapes. Prompt cannot guarantee output. Code can.
The proposed action surfaces as a card on the home screen. Both partners can vote. If both vote “ok”, an assignment is created for the coming week and shown as a banner on each partner’s home screen for the duration of the cycle.
This is a couples-native interaction primitive. Not a notification you tap to dismiss. Not a chore wheel you set up alone. A symmetric, low-stakes vote that creates a binding-for-a-week change. The assignment expires automatically at the end of the week, no escalation, no guilt. The product does not keep score of broken assignments. The product creates the space for the conversation, and lets the couple choose to recommit or move on.
Decision 4. Retention is a backend decision, not a mobile one
The retention spec for EqualHome was set on the backend before a single mobile screen was built.
The product insight: if logging requires reflection, people drop after three days. The product question: how do you make the daily log effortless, daily?
The decision: a daily push notification at roughly 8pm local time, asking “What did you do today?” Tapping the notification opens a one-tap multi-select sheet with three to five suggested tasks specific to that partner, that day of the week, that time of year. Confirm in one tap, the day is logged.
The infrastructure to make this work lives on the backend, not the app:
- An hourly Cloud Functions cron checks each user’s timezone (stored on the profile) and fires the prompt for everyone in the current local-time window.
- The suggestion engine runs on the backend, computing the most-likely tasks per partner from prior weeks, day-of-week priors, recurrence patterns, and an evening boost. The mobile client renders the result. It does not decide what to suggest, and it does not decide when to prompt.
- The batch log endpoint,
POST /logs/batch, is idempotent. A flaky network never double-logs. A re-tap never double-logs. The same request with the same payload returns the same result. - If the user has already logged three or more tasks earlier in the day, the prompt is skipped for them. We do not notify users for the sake of notifying. The push is the call-to-action of last resort.
This is the architectural shape of retention. The mobile app is a thin renderer. The state, the schedule, and the intelligence live where they can be updated without an App Store release. That choice is not free (it requires real backend work) but it is correct for a product where retention IS the product.
Decision 5. Pricing the couples loop
The freemium gate decision came up twice and almost went the wrong way both times.
Version 1: locks everywhere. Weekly insight gated. History gated. Assignment voting gated. €4.99/month or €49.99/year, -16% on the annual. RevenueCat for the entitlement, App Store and Play Store for the rails.
The problem: the assignment voting IS the couples loop. If you gate it, you have gated the thing that makes the product work for couples, in a couples product. Half the time you will have one partner paid and one partner free, and you have broken the loop for both.
Version 2 (shipped): gate revenue, keep engagement free.
Free, for both partners, forever:
- Daily logging.
- Balance Score with live counter-phase animation.
- Last 7 days of history.
- Assignment voting, assignment creation, assignment display.
Premium (€4.99/month or €49.99/year):
- Full weekly insight (free users see a teaser).
- Unlimited history.
- Monthly shareable report, generated as a 9:16 card with view-shot.
The couples loop (vote on assignment, balance the load this week) stays free for both partners regardless of subscription status. Either partner can pay for the deeper insight, the long memory, the shareable report. Nobody is locked out of the loop because their partner did not pay.
This is a couples-native pricing decision. It would be wrong in a single-player product, where gating the engagement loop is the standard freemium move. It is correct here, because gating the engagement loop in a couples product is the same as gating the product itself.
Implementation detail that mattered: the mobile reconciles premium status from two sources, the backend isPremium field AND RevenueCat directly, taking the union with upgrade-only logic. The first version of this naively trusted whichever source replied last, and downgraded an active premium subscriber to free when a backend webhook arrived late. Three angry users, one urgent fix, one rule for life: subscription state is upgrade-only on the client until you have explicit evidence of a cancellation.
The contract bug that came back three times
One war story, because every shipping log needs at least one.
The mobile app reads name, the backend serves nameFr. The mobile reads glyph, the backend serves emoji. The mobile reads enabled, the backend serves isActive. The mobile reads plan and isPremium, the backend serves subscriptionStatus.
Each mismatch caused a different visible bug: empty categories on the home screen, missing icons in the task picker, the paywall shown to a paying user. Each one was found, “fixed” in the mobile with a one-line hardcoded mapping, and forgotten. Until the next field rename caused it to happen again.
After the third occurrence, the architectural answer: a response mapper layer on the backend that translates internal model names to the explicit mobile contract, plus stable IDs (deterministic slugs, not random Firestore document IDs) so the mobile can reference categories by name across releases. Now the mobile contract lives in one file. When it changes, the build fails. The mapper became the single source of truth for what the mobile is allowed to see.
Ad-hoc field renames at the API layer are technical debt that compounds linearly with surfaces and quadratically with releases. Pay it once with a mapper layer, or pay it every release.
The 30-vulnerability audit
Short section, but worth its own heading because the work is usually skipped on solo projects.
Before the apps went into review, I ran a systematic security audit of every endpoint against a checklist of common vulnerabilities. The result: 30 issues identified. 4 critical, 6 high, 10 medium, 10 low. All closed before submission.
Some examples, picked because the lesson generalises:
- IDOR on insight reads. A user could mark as read an insight belonging to a different household. Fixed by adding an ownership check on every insight-mutating endpoint.
- Timing attack on the RevenueCat webhook secret. Fixed with a constant-time comparison.
- Replay on the RevenueCat webhook. Fixed by recording every processed event ID and rejecting duplicates.
- Prompt injection in LLM names. User-controllable fields (names, task labels) were being interpolated directly into the prompt. Fixed by sanitising and length-capping every user-controllable input before it reaches the model, and by schema-validating the model’s output before it reaches the app.
- Cost-DoS on LLM endpoints. The insight generation route had no rate limit. Fixed with per-household and per-IP limits that align with the actual usage pattern (one insight per week per household).
The point is not that any of these are exotic. The point is that most solo products ship without ever having looked. Looking is the decision.
What is in v1, and what is not
Honest scope at submission.
Shipped:
- Web marketing site bilingual FR/EN with a proper i18n architecture: locale routing under
app/[lang], GeoIP detection inproxy.ts(Next.js 16 convention), cookie persistence, hreflang, dynamic Open Graph image per locale vianext/og, JSON-LD Organization + MobileApplication + WebSite. - iOS and Android apps on Expo SDK 56 (React Native 0.85, Hermes). Firebase native (no JS SDK because Hermes incompatible). 13 instrumented analytics events. Sentry for crashes, breadcrumbs on every API call.
- Anonymous + Google + Apple sign-in. Apple required because Google is offered (guideline 4.8). Account deletion route with cascade, audit log retained 30 days (guideline 5.1.1(v)).
- Backend on Cloud Run europe-west1, 28 REST endpoints, 5 cron jobs, 16 unit tests, structured JSON logs via Pino, secrets in Google Secret Manager, CI/CD via GitHub Actions taking 4 to 6 minutes from push to smoke-test-green.
- Real premium gating (the previous version of the lock was cosmetic). Insight teaser for free users, 7-day history cap, report behind a mount-time guard, all reconciled with RevenueCat on app launch.
- Weekly LLM-generated insights with prompt injection guardrails and schema-validated outputs.
- ~430 strings translated FR/EN across the mobile app, with key validation between locales so missing strings break the build.
Not yet:
- Final production submission build, because two native changes (analytics without IDFA on iOS, AD_ID removal on Android) need to ship in a binary, not via OTA.
- Backend implementation of the transactional confirmation email ticket (spec is written, code is not).
- Store listing assets (screenshots, descriptions, App Privacy answers) in both FR and EN.
The reason “not yet” lives second on this list, not first: it makes clear that the scope cuts were deliberate and the work that remains is process work, not architecture work.
What this taught me
Three things I will carry into the next product.
1. When the unit is multi-user, friction compounds twice. Onboarding friction halves the funnel twice (once per partner). Notification fatigue compounds twice. Trust compounds twice. Pricing decisions that look correct in single-player products break in multi-user ones for reasons that are not visible on a single-user metrics dashboard. The hardest part of designing a couples product is keeping both partners in scope on every screen.
2. Retention is an infrastructure choice, not a feature. The decision to send the daily prompt at the right local time, with the right suggestions, idempotently, was made in the backend. The mobile app renders the result. This is how retention should be architected when the work is hard, because the mobile is the surface most likely to be wrong and the backend is the surface most easily fixed.
3. Contract layers earn their cost in week three. The mapper layer felt like overhead in week one. It is the architectural fix that stopped the bug from coming back in week three. Every consumer-facing shipping log I have written has a version of this lesson. The first time you ship, you can skip the mapper. The third time, you cannot.
Try EqualHome
- Web: equal-home-web.vercel.app/en
- iOS and Android downloads coming when App Store and Google Play release approve.