Inspiration
Most collectors own forty bottles and wear the same three. Every fragrance app on the store is built to sell you the forty-first — Fragrantica, Parfumo, and the rest are encyclopedias with a commercial agenda, and their recommendations are quietly untrustworthy because of it.
We wanted the opposite: an app whose daily recommendation has nothing to sell. A wardrobe, not a catalog. The bet is that "help me use what I already have" is a real, unserved job, and that being visibly uninterested in your next purchase is the only durable moat a small app can build against incumbents with a decade of data.
What it does
Open Aromatiquely in the morning and it picks what to wear today, drawn only from bottles you own, ranked on live weather, season, the occasion you choose, and what you haven't reached for lately. Every pick carries a reason — warm and resinous for a cold, clear morning; you haven't worn it in three weeks — because a recommendation you can't explain is one you won't trust. A silent pick is a bug, not a feature.
Log the wear in one tap. Over weeks it becomes a record of your fragrance life: rotation stats, what's sat untouched for ninety days, and a Scent Calendar that renders your month as a grid of the bottles you actually wore.
Cataloguing is where these apps lose people, so you photograph your shelf and it identifies the bottles against a licensed 8,000-fragrance catalog, tags each one by season, occasion, projection and time of day, and adds them in bulk.
When a month looks good, it makes a shareable shelfie card out of it — a magazine-style square you'd actually post.
New-purchase ideas exist in exactly one place — a separate Gaps screen you choose to open. They never enter the daily loop. That boundary is the product.
How we built it
Client: Expo SDK 57 / React Native 0.86 on the New Architecture, TypeScript in strict mode, Expo Router, NativeWind v4, Reanimated. SF Symbols for navigation chrome, a drawn icon set for content — split deliberately, because a foreign back chevron reads subtly wrong on iOS.
Backend: Supabase — Postgres with RLS on every user table, Auth (Apple + Google), Storage for bottle photos, and Deno edge functions for everything privileged. Drizzle is the schema source of truth; migrations are generated from it.
The architectural spine is the Repository pattern. The entire app codes against one TypeScript interface, never against Supabase. That let us build every screen against a mock implementation with simulated latency and a 2% injected error rate, iterate the UX on fixtures for a full phase, and then swap in the real backend without touching a single screen.
The recommendation engine is pure TypeScript in src/domain — deterministic, no network, no AI, unit-tested per axis. AI only enriches inputs and decorates outputs; it never makes the ranking decision.
AI runs server-side only, on OpenAI. Bottle identification is a cheapest-first ladder: OCR → Postgres trigram/tsvector fuzzy match against the catalog → gpt-4o vision only when ambiguous. Photos travel as inline base64 and are never persisted — no bucket, no temp uploads, no cleanup cron. Nothing to leak, because nothing is stored.
RevenueCat for subscriptions, PostHog for flags and bounded diagnostics, Sentry for errors. CI is GitHub Actions with a custom diff classifier deciding per-push whether to migrate, deploy functions, build, or ship an OTA. 746 tests across 87 suites.
Challenges we ran into
We shipped our licensed catalog to the internet. RLS was enabled on fragrances but the table was readable, and the Gaps screen queried it directly on every visit — meaning a single unauthenticated request with the bundled publishable key returned notes and accords for the entire catalog. Fixing it meant drawing a real line (notes require ownership; brand and name are facts) and rebuilding every catalog read as a hardened SECURITY DEFINER function. Then a second bug underneath the first: Supabase sets project-wide default privileges granting EXECUTE to anon, so revoking from PUBLIC left three functions still answering anonymous callers.
Days aren't timestamps. We stored UTC correctly and then read days by slicing the ISO string — which is exactly right at UTC+0 and wrong for every other human on earth. A wear logged at 23:30 in Delhi kept "Worn today" lit until 05:30 the next morning. The test suite couldn't catch it because it ran at UTC. We moved the whole suite to Asia/Kolkata permanently.
Two App Store rejections. The first for a missing in-app account deletion path. The second, Guideline 2.1(b), because our own deployment doc said subscriptions at "Ready to Submit" were submitted. They aren't — a subscription's first review is attached to an app version, and until that happens StoreKit vends nothing. So the reviewer's paywall showed "Pricing isn't available right now", RevenueCat silently dropped every package it couldn't price, and we found out from a screenshot stapled to a rejection letter.
Which exposed the real problem: our paywall diagnostics were gated behind a build-time flag that compiled them out of production — the one build that reached Apple was the one build that couldn't tell us anything.
Edge function contracts only move one way. Functions deploy on merge; app builds go through review and then wait to be installed, and iOS can't force an update. So a function permanently serves clients older than itself. Adding one value to a response enum is a breaking change that takes Add Bottle down for every build shipped before it.
Accomplishments that we're proud of
The Repository swap worked. We replaced the entire mock backend with real Supabase and exactly one screen file changed — and that one by choice. Every other screen, every hook, and the recommendation engine were untouched. That's the whole bet on the abstraction, paid out.
Privacy is architectural, not operational. Shelf photos can't leak because they don't exist anywhere persistent. Analytics is bounded by an allow-list test that fails the build if a fragrance name, a note, or anything the user typed enters a payload — a zero-result search reports its length.
Owner votes beat our heuristics, and we let them. We'd written a careful accord-driven ruleset for tagging and it declined on 46% of the catalog. Reading community vote distributions instead covered season and time-of-day for all 7,949 rows. Thousands of people who own the fragrance beat a clever function over accord names — so the ruleset got demoted to tier two.
AI costs almost nothing at steady state. Catalog tags are inferred once, cached on the fragrance row, and served from cache forever. A user adding a catalog bottle never triggers a paid call.
And it's live on the App Store.
What we learned
Observability is a product feature. We built a beautiful paywall and had no way to know it was broken. The lesson isn't "add logging" — it's that a diagnostic gated off in production is a diagnostic that exists only where you don't need it.
Verify the denial and the success. Locking down the catalog, we tested that anonymous callers were refused and shipped. Permissions are checked before a function body runs, so a passing denial test says nothing about whether the function still works — one of ours was broken for a full day behind a green check. Every hardening test now asserts both halves.
Write down why, not just what. Nearly every hard bug in this project traced back to a belief someone held that was never written down and turned out to be false. The repo's docs now carry the reasoning and the rejected alternatives, because the next engineer's real question is never "what does this do" — it's "why isn't it the obvious thing?"
Constraints are a design tool. No weekly plan, no lifetime tier, no free trial, no iPad, no chat UI. Each one was a real trade with a real cost, and writing that cost down is what stops it being relitigated every quarter.
What's next for Aromatiquely
Export my collection — with OAuth-only sign-in and Apple's Hide My Email, a lost provider account currently means a lost collection. Users should be able to walk away with their data.
Android, on the same codebase.
Paywall experiments — annual at $29.99 vs $39.99 first, then the free-tier bottle limit. Structural choices move conversion more than price does, so this is tuning, not strategy.
Occasion votes via a cross-source catalog merge — the one tag dimension still falling through to a model call, because our current source doesn't collect it.
Local-first sync, if offline write conflicts start to hurt. The data layer is already behind an interface, so it's one more implementation rather than a rewrite.
And the one we most want: closing the loop. Every wear can carry a rating, and the engine doesn't read them yet — it recommends, but it never finds out whether it was right. Teaching it to learn from how the day actually went is the difference between a good heuristic and a wardrobe that knows you.
Built With
- deno
- drizzle-orm
- eas
- expo-router
- expo.io
- github-actions
- gpt-4o
- ios
- jest
- nativewind
- openai
- postgresql
- posthog
- react-native
- react-native-reanimated
- revenuecat
- sentry
- sign-in-with-apple
- supabase
- tailwindcss
- tanstack-query
- typescript
- zod
- zustand
Log in or sign up for Devpost to join the conversation.