One Platform.OS branch decides whether our paywall gets a button or a sentence
Munchable has a free tier metered at five scans a month and a Premium subscription. It reaches people on three surfaces: the marketing site, the web build of the app, and the Android app. The subscription is one product
Munchable has a free tier metered at five scans a month and a Premium subscription. It reaches people on three surfaces: the marketing site, the web build of the app, and the Android app. The subscription is one product with one price, and yet "where may we ask for money" has a different answer on each of those three.
That is not a funnel decision. It is a platform rule, and the interesting part is how small the code that encodes it ends up being.
The rule, briefly
Apple's and Google's store policies govern how a native app sells digital goods, and they restrict steering a user from inside the app to your own payment flow. We have not integrated store billing. So the native app never sells Premium, full stop, and entitlement arrives from the account instead.
In a browser, none of that applies. A web page may send you to Stripe.
Our app is an Expo app that also builds for the web through react-native-web, which means the same paywall component renders on a phone and in a browser. The whole policy boundary is therefore one branch in one file:
{Platform.OS === 'web' ? (
// In a browser the user can pay directly with Stripe.
<Button label="Get Premium" variant="gold" onPress={onGetPremium} />
) : (
// Informational only on native. Premium is purchased on the web; the app
// never sells it in-app and unlocks it from the account entitlement.
<Text variant="body" muted>
Premium is available on our website. Visit munchable.app to see pricing and
upgrade, then it unlocks here automatically once you are signed in.
</Text>
)}
I have come to like having it in exactly one place. The alternative, a native-only build with the commerce code stripped out, scatters a policy question across a build configuration where nobody reviewing a pull request will see it. Here it is a ternary with a comment that names the reason, two lines above the thing it forbids.
Note what the native branch still does: it shows the price. Displaying what a subscription costs is not selling it, and hiding the number would be an odd kindness to nobody. The phone tells you the figure and tells you where it can be paid.
The checkout function that refuses to exist on a phone
The function behind the browser button is blunt about its own scope, starting from the first line of its doc comment:
// Start Stripe Checkout for Premium. WEB ONLY: the native apps never sell
// Premium in-app, and the paywall there is informational.
export async function startWebCheckout(): Promise<{ error?: string }> {
It asks our API for a Checkout Session and redirects the tab. One more detail in there that cost us a bug: it does not reuse the marketing site's cookie-based checkout call. A user inside the web app authenticates with a bearer token and has no landing-page session cookie, so reusing that path bounced them into a second login on the way to paying. Two surfaces, one Stripe, two different ways of proving who is asking.
The chooser page: the one screen whose job is to pick a surface
When you create an account on the site you land on /continue, a page with two buttons on it and nothing else to do.
"Continue in browser" hands your session to the web app on its own subdomain, so you arrive already signed in rather than logging in twice. The tokens ride in the URL fragment, which never reaches a server, and the app consumes them and strips them from the address bar immediately. I wrote that part up separately in Two subdomains, one session, and the only part of a URL a server never sees.
"Get the Android app" is the one that interacts with everything above. Pressing it does not go to the Play Store straight away. It switches the card to a second phase that offers Premium first, because this page is a browser page, and a browser page is the only place we are allowed to make that offer. Once you are in the app, the app can only point at the website.
const onDownload = () => {
// Already Premium: no upsell, straight to the store.
if (isPremium) {
goPlay();
return;
}
setPhase('premium');
};
The isPremium check matters more than it looks. The entitlement is fetched from the account, so somebody who already pays is sent to the store immediately and never sees an upgrade offer for the thing they have already bought. Showing it anyway is the single most common way a signup flow tells a paying customer that the product does not know who they are.
The other guard: a visitor who is not signed in is replaced to the sign-up page. The chooser has no meaning without an account behind it.
The same number in three places, and no conversions
The price shown on the chooser, in the phone's paywall and in the pricing table on the site all come from one shared workspace package. The base record is small:
export const PREMIUM_PRICE = {
currency: 'gbp',
unitAmount: 1000,
interval: 'month',
productName: 'Munchable Premium',
} as const;
What is not in that package is a currency converter. Each supported currency has its own figure written down, so the euro price is the euro price rather than a pound price run through a rate that drifts, which is a decision I wrote up in We stopped converting our price at checkout and wrote one figure per currency instead. The phone resolves its region and formats the figure for it; the site resolves the visitor's currency on the server so the first paint is already correct instead of a pound figure that gets replaced after hydration. Someone who reads the number in the app and then buys it on the site sees one number twice.
The small print follows the button rather than the page. The chooser's Premium phase renders the same footnote component as the pricing table, because that screen has a real upgrade button on it and therefore owes the same disclosure. The phone, which cannot take your money at all, carries one line:
export const VAT_NOTICE = 'VAT is included, so the price you see is the price you pay.';
A phone has room for a fact, not a column of caveats.
Look at it yourself
- Create an account at munchable.app/get-started and you land on the chooser at munchable.app/continue, which bounces you back to sign-up if you are not signed in. Press "Get the Android app" to see the browser-only Premium phase, including the footnote.
- The pricing table on munchable.app is where the figure is server-resolved per visitor. Load it through a VPN in another currency area and the price is a different written figure, not a converted one.
- "Continue in browser" takes you to the web build of the same Expo app the phone runs, so the paywall you can reach from its profile screen is the exact component quoted above, rendering its web branch.
- The Android app is on Google Play. Open its paywall and the same screen shows a sentence where the button was.
- The terms that the chooser's footnote refers to are at munchable.app/terms.
If you ship the same code to a store and to a browser, it is worth finding the single narrowest place where the store's rules change your behaviour, and putting the comment that explains why right next to it. Ours is a ternary. The version of this where the rule lived in three places was, briefly, the most nervous code in the repository.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.