DEV Community

Cover image for I built and launched a mobile app in 3 months as a solo engineer. Here's exactly what happened.
Dusty Mumphrey
Dusty Mumphrey

Posted on

I built and launched a mobile app in 3 months as a solo engineer. Here's exactly what happened.

You breed reptiles. At any given time you're tracking weights, feeding schedules, clutch dates, pairing history, and morph genetics across dozens of animals. Every tool that exists was built for something adjacent but not quite right. Spreadsheets, generic pet apps, pen and paper. So you build the thing that doesn't exist yet.

That's ReptiDex. Here's what three months of building it actually looked like.


The Problem

Reptile breeding is a data problem masquerading as a hobby.

Take a single clutch of ball pythons. You need to record two parents, each with their own morph genetics, lineage, and acquisition history. The clutch has a lay date, an expected hatch window, and an incubation temperature log. Each egg hatches separately. Each hatchling gets its own weight series. You're weighing at 30, 60, and 90 days to assess growth. Each animal that sells needs a buyer record and a price. The ones you keep get folded back into future pairings.

That's one clutch. A serious breeder might run 20 to 40 clutches in a single season across multiple species: ball pythons, leopard geckos, crested geckos, monitors. Each species has different care requirements and genetic notation systems.

Spreadsheets have the right instinct but the wrong tool. You can model anything in Excel if you're willing to engineer it yourself, but you'll re-engineer it every year as your operation grows. The existing mobile apps treat reptiles like fish. Something you keep, maybe photograph, and occasionally feed. None of them model breeding pairs, lineage graphs, or clutch-level weight tracking. None of them handle multi-user collections where a partner also needs access.

ReptiDex was built to solve these specific, concrete data problems. Every feature traces back to something that was genuinely annoying to do in a spreadsheet.


The Stack

React Native with Expo. I needed iOS and Android from day one. The breeder market splits fairly evenly across platforms and launching single-platform would have halved my addressable market. Expo's managed workflow meant I could focus on product rather than Xcode configuration and native module wrangling. The tradeoff is occasional framework constraints, but for a solo launch the velocity win is decisive.

Supabase. PostgreSQL with built-in auth, storage, and row-level security. The appeal wasn't just a managed Postgres instance. It was RLS, which solved multi-tenancy at the database layer rather than the application layer. Every query is automatically scoped to the authenticated tenant. It's not magic, but it removes an entire class of authorization mistakes from the codebase.

TanStack Query and Zustand. Server state and client state are different problems that benefit from different tools. TanStack Query handles async fetching, caching, and background refresh. Zustand manages UI state that doesn't need to live on the server. Keeping these concerns separated made the codebase easier to reason about as features grew.

RevenueCat. Subscription management is not something you want to build yourself. Receipt validation across iOS and Android is a rabbit hole. RevenueCat abstracts both payment flows into a single API and gives you a real-time subscription dashboard. The three-tier model (Free, Pro at $4.99/month, Premium at $9.99/month) was straightforward to implement once it was wired in.


The Build

I started with the mobile app and worked from the inside out. Authentication first, then the dashboard. I spent more time on that dashboard than anything else in the early build, because it's where users live. Reptile keepers are using this app in their reptile rooms with dirty hands and an animal that needs attention. The dashboard needed quick actions. Logging a feeding, recording a shedding, marking a hatching. These had to be two taps, not five. Friction at the feature level means the app stops getting used.

Once I was happy with the core husbandry layer, I moved into the breeding side. This was the part I personally needed most as an active crested gecko breeder, and I built it the way I build my own workflows: iteratively, inside my own operation. If a feature had too much friction, I felt it before any beta tester did. That's how the breeding pairs, clutch records, and lineage system got to where they are. Not from a spec. From actual use.

After the mobile app was solid, I replicated the full experience as a web version. The core logic lived in a shared service layer from the beginning because I knew cross-platform was the goal. The UI needed platform-specific adjustments, but the business logic underneath was built once.

The final push before App Store submission was security hardening, extensive usability testing, and polish. Multi-tenant architecture has real attack surface if you're not deliberate about it, and I'd built with security in mind throughout. But I wasn't going to ask people to pay for something I hadn't pressure-tested. I brought in beta testers to find the friction I'd gone blind to after three months of building.

The hardest stretch was the end. Every time I thought it was ready, another critical bug surfaced that had been sitting quietly in the codebase waiting for exactly the wrong moment. Some of that was real. Some of it was my own perfectionism making it hard to call something done when I knew people would be paying for it. I was building on evenings and weekends around my day job the entire time. When I wasn't working, I was working on ReptiDex.


One Decision That Paid Off

The pedigree resolution system is the most technically interesting problem ReptiDex solved, and getting it right early paid off throughout the rest of the build.

Here's the domain problem: a reptile's pedigree includes parents, grandparents, and great-grandparents. In a single-breeder app this is simple. Every animal in the tree lives in the same database tenant. But ReptiDex supports cross-tenant lineage linking. If you bred your female with another breeder's male, that sire lives in their collection, not yours. The two tenants are separate. The sire's owner controls its visibility.

This means "who is this animal's sire" is not a simple foreign key lookup. It's a resolution problem with four possible outcomes for any given parent slot:

1. A lineage_links row exists + linked animal is accessible  → linked-live
2. A lineage_links row exists, but the source record is gone → linked-archived (snapshot)
3. No lineage_links row, but sire_id/dam_id is set           → local
4. None of the above                                         → unknown
Enter fullscreen mode Exit fullscreen mode

Here's the actual resolution function:

async function resolveParent(
  currentAnimal: AnimalWithPedigree,
  role: 'sire' | 'dam'
): Promise<ResolvedParent> {
  // 1. Check lineage_links for a cross-tenant link
  const link = await LineageLinksService.getLinkForRole(currentAnimal.id, role);

  if (link) {
    if (link.status === 'pending') {
      return { source: 'linked-pending', snapshot: link.snapshot, linkId: link.id };
    }

    if (link.status === 'approved') {
      if (!link.linked_animal_id) {
        return { source: 'linked-archived', snapshot: link.snapshot, linkId: link.id };
      }

      const { data: linkedAnimal, error } = await supabase
        .from('animals')
        .select(ANIMAL_SELECT)
        .eq('id', link.linked_animal_id)
        .single();

      if (!error && linkedAnimal) {
        return {
          source: 'linked-live',
          animal: linkedAnimal,
          linkId: link.id,
          isSameTenant: linkedAnimal.tenant_id === subjectTenantId,
          snapshot: link.snapshot,
        };
      }

      return { source: 'linked-archived', snapshot: link.snapshot, linkId: link.id };
    }
  }

  // 2. Fall back to within-tenant sire_id/dam_id
  const parentId = role === 'sire' ? currentAnimal.sire_id : currentAnimal.dam_id;
  if (!parentId) return { source: 'unknown' };

  const { data: localAnimal } = await supabase
    .from('animals')
    .select(ANIMAL_SELECT)
    .eq('id', parentId)
    .single();

  return localAnimal ? { source: 'local', animal: localAnimal } : { source: 'unknown' };
}
Enter fullscreen mode Exit fullscreen mode

The key design decision is that lineage_links takes strict priority over sire_id/dam_id. If a cross-tenant link exists, we always use it, even if a local FK is also set. This keeps the source of truth unambiguous and prevents a pedigree from silently splitting across two resolution paths.

This function also handles a subtle real-world case: what happens when another breeder deletes an animal you've linked to? Instead of a broken reference, linked-archived kicks in and renders from the immutable snapshot stored at link-creation time. The pedigree stays complete even if the external record disappears.


One Decision That Bit Me

Offline mode was the right product decision and the wrong scope decision for a three-month launch.

Breeders work in enclosure rooms, outbuildings, and basements with poor signal. An app that requires connectivity to log a feeding or record a weight fails at exactly the moment it's needed. So I built an offline-first architecture with a sync queue. Mutations go into a local queue, apply optimistically, and sync when connectivity returns.

The implementation works. But it added meaningful complexity to every feature. Every mutation had to be written twice. Once for the optimistic local update, and once for the server sync. Edge cases multiplied: what happens when the same record is edited offline on two devices? What happens when a sync fails midway? Testing sync behavior in Jest required simulating network conditions, which is unpleasant.

The smarter move would have been to launch without it, ship to a beta group, and measure how often the connectivity issue actually came up. It might have been less common than assumed. Offline support could have been a v1.1 feature built from real user feedback rather than projected pain. The technical work wasn't wasted, but it extended the build more than it needed to for day one.


The Launch

App Store submission was relatively smooth, with two exceptions. It got rejected twice. Once for missing terms of use in the description, and once for a small metadata issue. Neither was a technical problem. Both were fixable within a day. The review process taught me that the App Store will find the things you didn't think to check. Submit earlier than you think you need to.

The moment it went live, I started sharing in the reptile groups I was already part of. These weren't cold audiences. I was a member of these communities, not a marketer dropping a link. Shortly after launch I partnered with a reptile enclosure company for a giveaway, which gave the early momentum a real push.

In the days after launch I was watching Sentry, PostHog, and my own custom analytics dashboard more than I'd like to admit. When that first paid subscriber came in, I was over the moon. It instantly made every late night worth it. I had shipped products before, for fintech companies and healthcare platforms, as a cog in someone else's pipeline. This was different. The planning, the business model, the branding, the code, the users. All of it was mine. That subscriber wasn't a metric in someone else's dashboard. It was proof that something I built from scratch, alone, solved a real problem well enough that a stranger handed over their credit card for it.

Within the first nine days: 50 paid subscribers and 200 animals tracked.

Those numbers matter because they weren't free users. These were paying customers who converted fast. That's confirmation that the problem was real and the execution was good enough to earn trust on day one.


What I'd Do Differently

Narrower first scope. The feature set that shipped was comprehensive: weight tracking, breeding pairs, clutches, lineage graphs, care guides, import/export, multi-user collections, and notifications. It all works. But a leaner v1 built around weight tracking and basic animal records would have shipped faster and generated earlier signal about what the market actually paid for.

Offline mode as v1.1. Covered above. Ship when you have evidence of the problem, not anticipation of it.

Earlier App Store submission. The review process surfaces things you didn't think to check: edge cases in permission request flows, metadata requirements, and small policy details that can kick off another review cycle. Getting into the queue earlier reduces the blast radius of late discoveries.


What's Next

ReptiDex is a live product and I'm still building it. The backlog is now shaped by what users are actually doing inside the app, which turns out to be a better requirements document than anything written before launch.

I also run Built By Dusty, a software studio that builds custom applications for small businesses and animal breeders. If you're a breeder who wants to try the app, or a founder with a domain that has messy, specific data requirements that off-the-shelf tools can't handle, I'd like to hear from you.

Top comments (0)