
Norvayo
Norvayo is a privacy-first app for quitting nicotine pouches
I'm building Norvayo, an app for quitting nicotine pouches (Zyn, Velo, On, that whole category). Solo, nights and weekends. It's not launched yet, so treat everything below as a decision I've made rather than one I've validated.
The hardest problem so far wasn't technical. It was figuring out what I'm allowed to charge for.
Here's the tension. I want this to make money. I'm not building it as charity, and I don't think there's anything noble about undercharging for something useful. But the product is a tool people use while trying to break an addiction. If someone's on day 9 of quitting and the app says "your streak is behind a paywall," that's not a growth tactic. That's leverage against somebody at their worst.
So I needed a rule that wasn't just "gate whatever converts best."
The rule I landed on: anything that helps you get through the next hour stays free forever. Anything that analyzes, automates, or connects gets paid.
In practice that meant:
Free, permanently: logging pouches and cravings, the coping toolkit (breathing, timers, distraction actions), your streak and badges, money-saved math, a full taper plan that steps your daily limit down to zero, one private accountability partner, and full data export.
Paid: an adaptive scheduler that spreads your daily limit into paced windows timed around your historically hardest hours, pattern analysis across your triggers and timing, longer trend charts, and small group support instead of just one partner.
The line I kept coming back to: the quit plan itself is free. You can taper to zero and never pay me a cent. What you're paying for is the version that thinks about it for you.
The thing I got wrong.
I gave everyone 14 days of full access, then locked the premium features. Seemed obvious.
Then I realized my premium features are at their weakest during exactly those 14 days. The pattern analysis needs accumulated history before it can say anything. The adaptive scheduler needs enough logs to know which hours are hard for you. The longer trend charts need more days than a new user has. I'd built a trial that showcases my paid tier at the precise moment it has the least to offer.
I'm still not sure how to fix that. Options I'm weighing: start the trial after enough data exists rather than at signup, lower the thresholds during trial so insights fire earlier, or reorder the pitch around the one premium feature that works on day one (the group support, which needs no history at all). Probably some combination. If you've solved this in a data-dependent product, I'd genuinely like to hear it.
The other thing I'm unsure about.
One of my gates is "trend charts beyond 7 days." I gated it because it was easy to gate. But nobody has ever subscribed to anything because of a chart, and in a recovery app it risks reading as "we're hiding your own data from you." I suspect that gate costs me more in goodwill than it earns. Haven't removed it yet. Probably should.
Where I actually am.
Zero users. I'm mid-way through Google's 12-testers-for-14-days closed testing requirement, so none of this is validated. I might discover that people happily pay for the charts and don't care about the group feature, and that my whole framework was me reasoning from my own preferences.
What I do believe is that having an explicit rule made the decisions faster and more consistent than doing it feature by feature. When I added something new, I asked whether it helps someone survive the next hour. If yes, it's free. That's answered every case so far without me relitigating it.
Curious how others have drawn this line, particularly in health, fitness, mental health, or recovery. Where does "sustainable business" stop and "exploiting the moment someone needs you" start? I don't think there's a clean answer, but I'd rather hear how other people reasoned about it than pretend I've got it figured out.
My production build failed at the Gradle bundling step with hundreds of these:
index.android.bundle:3761:5: error: private properties are not supported <unknown>:0: error: too many errors emitted
The offending code was in React Native's own internal DOM shims — DOMRectReadOnly, EventEmitter, Performance. All using ES2022 private class fields (#x). Nothing I wrote.
Those fields are supposed to be transpiled away before reaching Hermes. On this React Native version, Hermes can't compile them to bytecode.
Cause: I had babel-preset-expo pinned to ^57.0.4 in devDependencies. My project is Expo SDK 54, and expo bundles its own nested babel-preset-expo@54.0.12 for internal use.
My babel.config.js says:
presets: ['babel-preset-expo']
Normal Node resolution finds the top-level copy. The wrong one. Version 57 targets a newer Hermes that supports private fields natively, so it stops transforming them. My actual Hermes couldn't handle them.
How it got there: I added babel-preset-expo while setting up Jest, and grabbed the latest version without thinking about it. It looked like a test-only dependency. It isn't — Metro uses the same babel.config.js for real production bundling. A "test dependency" was silently corrupting my shipping build.
Why nothing caught it: tsc passed. 161 Vitest tests passed. 12 Jest component tests passed. None of them exercise Metro or Hermes. The only thing that fails is a real build.
Fix: pin it to your SDK's line.
"babel-preset-expo": "^54.0.0"
Reproducing this locally instead of burning cloud builds. This is the internal command the RN Gradle plugin runs, so it reproduces the exact failure:
npx expo export:embed --entry-file index.ts --platform android \ --dev false --minify false --unstable-transform-profile hermes \ --bundle-output /tmp/b/index.android.bundle \ --assets-dest /tmp/b/assets --reset-cache
Then compile it with the Hermes binary that ships in your node_modules:
./node_modules/react-native/sdks/hermesc/<platform>-bin/hermesc \ -emit-binary -out /tmp/b/index.hbc /tmp/b/index.android.bundle
Exit code 0 means CI will pass. That turned a 20-minute round trip into 40 seconds. (Plain npx react-native bundle won't work in an Expo project — @react-native-community/cli isn't installed.)
The generalizable lesson: if you add a tooling dependency that has a same-named counterpart already bundled by your framework, match the framework's version line. Don't take latest. A mismatch there can silently corrupt your real build pipeline while every test stays green, and you won't find out until you try to ship.
A note on all three: these are drafted from what actually happened, but I'd read each one over before posting and cut anything that doesn't sound like you. Also double-check the Hermes path in post 3 against your own node_modules before publishing — it's win64-bin on your machine, but you'll want the generic phrasing for other readers, which is what I've used.
1 Like
Comment
Play Console rejection, within hours of submitting:
Play Console Requirements: Violation of Play Console Requirements. Some types of apps can only be distributed by organizations. You have selected an app category or declared your app offers certain features that require you to submit your app using an organization account.
I'm a solo developer on a personal account. An organization account means forming an LLC, getting a D-U-N-S number, registering a new developer account, and transferring the app. Weeks of work and real money I hadn't planned to spend.
The cause was one checkbox.
Since August 31, 2024, new developer accounts must register as an organization if the app provides financial services, health apps such as medical apps, VpnService apps, or government apps.
In Play Console → App content → Health apps, the declarations are grouped under headings. I'd checked two boxes:
"Stress management, relaxation, mental acuity" — under Health and fitness
"Mental and behavioral health" — under Medical
The second one put me in the medical bucket. My app is a habit tracker for quitting nicotine pouches. It has coping exercises and logging. It does not provide therapy, diagnosis, counseling, or anything clinician-directed, and its own terms carry a prominent "this is not medical treatment" disclaimer.
There was even an inconsistency in my own declaration: I'd deliberately left "Diseases and conditions management" unchecked for exactly that reason, then checked a box sitting in the same Medical block.
I unchecked "Mental and behavioral health," kept the stress-management one, and resubmitted. Published the same day.
Please read this part before you copy me. This is not "uncheck boxes until Google lets you through." Google's own remediation text says to make sure your declarations accurately reflect your app's features, and that's the actual standard. If your app genuinely provides medical or behavioral-health services, the organization requirement applies to you and unchecking it is misrepresentation that can cost you the account.
My case was over-declaration: I'd been cautious and checked anything that sounded adjacent, without noticing that one of them carried a completely different regulatory weight. Correcting an inaccurate declaration is legitimate. Gaming it is not, and I wouldn't have done it if I thought the box was accurate.
Two things worth knowing regardless:
The category headings carry real consequences. Everything under Medical implies clinical function. Read them as legal classifications, not as topic tags.
Also worth checking what your competitors declare. The two closest apps to mine list themselves as Lifestyle and Utilities, not Health & Fitness. I'm not saying pick a category to dodge review, but it's worth knowing that "habit tracker" is a defensible reading of what these apps actually are.
And a separate lesson from the same week: completing pages in Play Console does not submit them. All my work sat in "Changes not yet submitted for review" for days while the app stayed in Draft, the store URL 404'd, and tester installs failed with "Item not found." Check Publishing overview.
1 Like
Comment
I'm building an Expo/React Native app. Last week I installed my own production build from the Play Store, opened the paywall, and got "Purchases aren't available here."
Odd, because purchases worked fine in my dev build. So did cloud sync. So did everything.
Root cause: .env is in .gitignore, and EAS Build uses .gitignore to decide what to upload. My .env never reached the build server. Every EXPO_PUBLIC_* variable resolved to undefined in the cloud.
That meant three production builds shipped with:
no Supabase URL or key (auth and sync silently dead)
no RevenueCat key (paywall dead)
no Sentry DSN (crash reporting dead, so nothing reported the other two)
Nothing errored. The app launched, logged data locally, looked completely normal. Features just quietly did nothing.
Local builds masked it perfectly, because they read .env straight off disk.
What made it take a day longer than it should have: I found this line in the cloud build log and read it as confirmation that .env was loaded:
The NODE_ENV environment variable is required but was not specified. Using only .env.local and .env
It isn't. It only states which files Expo would consider. It says nothing about whether they exist.
The actual tell was the absence of something. Local builds print:
env: load .env env: export EXPO_PUBLIC_SUPABASE_URL EXPO_PUBLIC_...
My cloud logs had zero occurrences of env: load, and zero occurrences of the string EXPO_PUBLIC anywhere in the entire build log. That was sitting there the whole time.
The fix. Put every public var in EAS's own environment store:
npx eas env:create --name EXPO_PUBLIC_FOO --value bar \ --environment production --visibility plaintext npx eas env:list --environment production # what builds actually use
A correct build then prints, near the top:
Environment variables ... loaded from the "production" environment on EAS: EXPO_PUBLIC_SUPABASE_URL, EXPO_PUBLIC_...
If that line lists nothing, your build is broken.
How to actually verify rather than trust it. Unzip the AAB and grep the JS bundle, always with a control string you know is present:
unzip -q app.aab -d x B=x/base/assets/index.android.bundle grep -a -c "Some UI String You Wrote" $B # control grep -a -c "your_api_key" $B # the thing under test
One caveat that cost me another hour: Hermes stores any string containing a non-ASCII character (em dashes, curly quotes) as UTF-16, where ASCII grep will never find it. If your UI copy has em dashes in it — mine does, everywhere — search the raw bytes both ways:
data = open(bundle,'rb').read() data.count(b"plain ascii string") data.count("string with — dash".encode("utf-16-le"))
The generalizable lesson: any config that lives only in a gitignored file is invisible to your CI. That's the entire bug. It's obvious in hindsight and completely silent in practice, and I'd bet a lot of Expo projects have it right now without knowing, because local builds never complain.
1 Like
Comment
About
Norvayo is a privacy-first app for tracking, tapering, or quitting nicotine pouches — with an adaptive daily plan, a private accountability partner, and progress calculated honestly from your own data. No data sold.

1 Comment
The trial problem is especially interesting because the product needs time to become more useful.
That creates an unusual tension: the moment you normally ask someone to evaluate the paid experience may be the moment it has the least evidence to work with.