Luxura
Invitation-based dating app, Bogotá and Medellín. Eleven weeks from an empty repo to Google Play. I built it, I run its server, I paid for its ads. Every number below came out of the database it is still running on.
- Role
- Founder, technical lead: product, backend, infrastructure, growth
- Design
- Victor ↗
- Build window
- 2026-01-21 → 2026-04-06
- Runs on
- One Hetzner box, Coolify, Docker



$ cat ORIGIN.md
It started as an observation, and a friend who cared how it looked.
Dating apps were missing a lot. The experience felt cheap and the incentives were wrong. But a few apps had already validated the better model, where one side invites and the other side chooses, so the idea was not the risk. The execution was.
I asked myself why I could not build a better one. It began as a small project and turned into something I could not put down. Victor designed it with me, screen by screen: the login, the profile cards, the landing pages, the dark and gold direction the whole thing still runs on. We went slowly on purpose. The goal was never the most features. It was something that felt good to use and looked like somebody had cared.
$ cat platform.md
A website was fine. Most users wanted an app.
Capacitor made the web app and the Android app one artifact: one codebase, one deploy, as long as I stayed disciplined about responsive layout and wired the native plugins for push, GPS and the camera myself.
Then came the questions nobody warns you about. Publishing to Google Play takes weeks. It is two weeks of mandatory closed testing with twelve testers before you are allowed near production. Payments were another: RevenueCat for store subscriptions, and no web payments at all in the first release, because one billing surface was enough to get wrong. iOS I deferred and never shipped. The Capacitor target is in the repo. Android is where this market is.
One consequence is still my favorite thing about the build. The shell loads the web app live from my server, so a JavaScript change reaches every installed phone on the next deploy. A store release is only needed when native code changes.
$ cat README.md
Open dating apps drown in low-effort volume.
Everyone sends, nobody chooses, and the people receiving leave first. Luxura inverts it. One side browses and spends a strictly limited number of invitations. The other side receives them and chooses. Scarcity on the sending side is the product. An invitation costs the sender something, so it means something to the receiver.
Other apps had already validated the model, so the execution was the part I wanted. It started as a project with a friend and turned into a product with an ad account attached to it.
# Constraints
- Colombian market. Price ceiling around $5/month, not $15.
- Android-first, mid-range phones, mobile data. Bundle size decided what shipped.
- Solo budget. No vendor above tens of dollars a month.
- Spanish and English at parity from day one, 1,911 keys each.
$ ./bin/report --totals
$ ./bin/report --ads-off 2026-03-08
I paused paid acquisition for one day to find out what I had actually built.
Everything looks healthy while the ads run, which is exactly when you cannot tell demand from spend. So I turned the tap off for a day. The more product-like the metric, the less it moved.
7 Mar, ads on8 Mar, ads off, colored by how far it fell
Top-of-funnel was almost entirely bought. The 48 signups that still arrived with the ads off are what organic discovery looked like. But the people already inside kept talking. Chat was three days old and was already the stickiest thing in the product. That comparison set the roadmap for the next two weeks. I also pulled a retention table that week and will not use it here: it measures a recency window, so it flatters whoever signed up last, and the ads-off day is the cleaner comparison because both cohorts were measured on the same day.
$ cat sdd/proposals/role-based-app-overhaul.proposal.md
Half the traffic I was paying for died before it saw another human being.
Production was sound and everything was tested. Then the ad account opened and the money started evaporating at the door. Registration ran phone, then profile, then photos, then a live face check. Only then did you see a single profile.
I did not patch it on instinct. I measured, wrote the diagnosis down, and shipped the fix.
## Motivation (Why) The current onboarding has a 47% completion rate (846 users stuck at verification). Gender is hardcoded to role (MALE=inviter, FEMALE=invitee), which blocks same-sex dynamics. **Core insight**: Let users see the candy shop immediately, but gate interaction behind profile completion and payment.
Verbatim from the proposal, dated 2026-03-09. It shipped two days later as one change: register, pick a role, pick an orientation, and you are in. Inviters get the full browse feed immediately. Invitees get a labelled demo inbox populated with real profiles. The three-step wall still exists. It just does not rise until you try to invite or accept. The gate moved from the entrance to the interaction.
$ psql -c "verification completion, before vs after"
SELECT CASE WHEN u."createdAt" < '2026-03-11' THEN 'before' ELSE 'after' END AS era, count(*) AS with_profile, count(*) FILTER (WHERE u."onboardingStep" IN ('VERIFIED','ACTIVE')) AS verified, round(100.0 * count(*) FILTER (WHERE u."onboardingStep" IN ('VERIFIED','ACTIVE')) / count(*), 1) AS pct FROM users u JOIN profiles p ON p."userId" = u.id GROUP BY 1; era | with_profile | verified | pct ---------+--------------+----------+------- before | 1994 | 1027 | 51.5 after | 1057 | 678 | 64.1 (2 rows)
Run against the live database in August 2026. Across all signups the lift is smaller, 44.3% to 48.7%, because the change also let in a large group who never started a profile at all. Week by week: the worst ad-driven week verified 40.4%; by 23 March that reached 56.8%.
1,239 users now sit at the first step, inside the app, never having built a profile. That is a real leak and it is the one I would have attacked next. It is still the better leak to have: those people saw the product before they decided, which was the entire point.
$ ls -la ./engineering
face-api.js wanted roughly 10MB of models. On a mid-range Android phone over Colombian mobile data that is a dead product before it renders. Jeeliz WebGL does the gesture work at about 1.4MB. Human.js stayed as a second engine behind an environment flag, and in auto mode it loads first and drops back to face-api.js without a visible failure, because one implementation means one point of failure, and a user who cannot verify is a user who leaves.
// content moderation: resolve in order, first success wins 1. self-hosted NudeNet + InsightFace + OpenCV // safety AND quality 2. Azure Content Safety ‖ Computer Vision // in parallel 3. Google Cloud Vision // one call, both 4. Sightengine nudity · offensive · type · face // flagged no_face | screenshot → a small vision model re-judges THAT flag // video → frames pulled and judged one at a time // rejected → stored as a perceptual dHash, re-upload caught for free
The big vendors cry wolf, and a false rejection costs a real user who is already halfway out the door. So a flagged photo gets its specific flag re-judged by a small vision model, independently, and only an all-clear across every flag upgrades it. The cheapest piece is my favorite: every rejected photo is kept as a perceptual hash, so a re-upload of the same image gets caught instantly, with no vendor call at all. People re-upload constantly.
Images compress in the browser at a 500KB / 1920px cap, so 80 to 90% of the bytes never leave the phone. Video compresses server-side in a background job (H.264, 720p, CRF 23) with a processing flag on the row so the UI can show state while it runs. Roughly 87% storage reduction for no marginal cost beyond CPU I was already paying for. When you pay for your own ads, every recurring vendor line comes out of the same budget.
Socket.IO attaches to an HTTP server that wraps the Next.js request handler, which is why this app starts with a custom entry point and not next start. That closes a real door. No serverless host will run this again. It was the right trade because the alternative was polling, and because I already ran my own box. 424 messages by the next morning, 71.4% of connections chatting, and 15,480 messages today.
// phone OTP, eight weeks raw Twilio → Twilio Verify → raw Twilio Messaging → Firebase Phone Auth → Plivo Verify → Twilio + own otp_codes table → Infobip + Twilio fallback // three of those swaps happened on a single day: 2026-03-14
Every move came from Colombian delivery rates, per-message cost, or an error surface I could not control. One Twilio error code alone flooded Sentry with what were really user typos. Nothing in the app matters if the first screen leaks users, so I stopped treating it as a procurement problem. The code now owns the codes, the rate limiting and a circuit breaker, and the provider behind it is a config value. Once that seam existed, changing primary took a day instead of a sprint. It is where I should have started.
$ cat economics.md
I paid for the ads myself, so every decision came back with a price per signup attached.
The spend and the per-unit figures cover the paid-acquisition period to 9 March, when there were 2,451 signups. The totals at the top of this page are lifetime, to August.
Eight hundred dollars showed me where the funnel leaked. The subscription launched at $15/month into a market that supports about $5. Weekly plans at roughly $2.50 shipped on 17 March, a month after the money was already committed.
I specified attribution properly on the next project, before any spend, because of this exact line. Paid acquisition without attribution left me guessing: I can tell you what $800 bought in aggregate and I cannot tell you which campaign did it.
$ cat scope.json
Plus: CI running lint, typecheck and build on every push · Sentry across client, server and edge with a tracked bug log · deep links and store asset-link files · rewarded ads through AdMob · RevenueCat subscriptions with a nightly reconciliation job · a Playwright suite across onboarding, browse, invitations, verification and admin · and a growth stack that auto-publishes to X, Threads and Facebook with canvas-rendered quote images.
$ cat method.md
1,326 commits in eleven weeks, alongside the ad account.
That came from an agentic workflow, and more from the guardrails that make one survivable.
- A pre-ship checklist where every item maps to a real incident. Item 4 exists because a function passed from a Server Component to a Client Component took the site down on 19 March.
- Spec-driven development for anything crossing more than one subsystem: proposal, spec, numbered tasks, all committed. The funnel rebuild shipped that way.
- A blind second-model review before every push, run by a non-Anthropic model. The monia teardown has the measurement that justifies it.
- CI that will not let a type error reach the server, because one did.
The volume matters less than what it was spent on. What changed were the failure modes. Fewer typos and forgotten null checks; far more correct module, never wired up at the composition root. The checklist is aimed squarely at those.
$ ls ./ad-creative
I made the ads too, and that is where the next product came from.
Nobody was going to hand me creative. I generated the video in Kling, using motion control to get the camera moves I wanted. For the voiceovers I recorded a take and then swapped the voice with Cartesia or ElevenLabs until the register sounded like Bogotá rather than a translation. Then I posted. Instagram, Facebook, several profiles, every day, by hand, on top of the build. Generating a clip took minutes, and tending the output took the rest of the day.
That daily tending is what produced my next product, which has its own page.



$ cat POSTMORTEM.md
It did not take off, and it pointed at the next thing.
Luxura is live and people use it. The product side held. The day I paused ads, signups fell 83% and messages fell 11%. What I could not sustain was the spend that keeps the top of the funnel alive, at two paying subscribers against $800.
The ad work above is what changed the direction. Somewhere in the daily tending of those accounts I asked why I was doing it by hand, and friends running small shops turned out to have the same problem from the other side, drowning in messages they could not answer. That is where Storía came from. I built it in four days that February, in the middle of this.
# What I would do differently
- Let people in before asking them to prove anything. I learned this by buying it. Fixing it moved verification completion among profile-starters from 51.5% to 64.1%, after the ad spend, not before.
- Ship the conversation on day one. Chat was the only feature retention responded to, and it arrived in week seven. Everything before it was a matching engine that handed people to WhatsApp.
- Own the commodity vendors from the start. Four SMS providers across seven configurations, and four moderation providers, taught me the same lesson twice: keep the state in my database, make the vendor a config value.
- Price for the market before buying traffic. $15/month into a $5/month market wasted most of the $800. The weekly plan that fits Colombia shipped a month late.
- Never encode a demographic assumption in the schema. Gender sat in the routes, the copy and the tables at once, so replacing it with a role cost a week instead of a day.
- Buy attribution before buying ads. source: unknown, 2,026 rows.