●RORK MAX — Rork Max can now build native Swift apps for iPhone, iPad, Apple Watch, Apple TV, and Vision Pro●PUBLISH — Rork Max offers two-click App Store publishing with no Xcode required, cutting the friction of getting an app shipped●EXPO — The standard Rork is built on React Native (Expo), generating native iOS and Android apps from plain-English descriptions●PRICING — Rork is free to start, with paid plans beginning at $25/month, an accessible tier for solo developers●FUNDING — Rork raised $2.8M from a16z (Andreessen Horowitz) as investment keeps flowing into AI app builders●REVIEW — In real use the keys are generated-code readability and maintainability, Expo-related constraints, and how easily billing, push, and ad SDKs slot in●RORK MAX — Rork Max can now build native Swift apps for iPhone, iPad, Apple Watch, Apple TV, and Vision Pro●PUBLISH — Rork Max offers two-click App Store publishing with no Xcode required, cutting the friction of getting an app shipped●EXPO — The standard Rork is built on React Native (Expo), generating native iOS and Android apps from plain-English descriptions●PRICING — Rork is free to start, with paid plans beginning at $25/month, an accessible tier for solo developers●FUNDING — Rork raised $2.8M from a16z (Andreessen Horowitz) as investment keeps flowing into AI app builders●REVIEW — In real use the keys are generated-code readability and maintainability, Expo-related constraints, and how easily billing, push, and ad SDKs slot in
Don't Show Overseas Users a Hardcoded '$3.99' — Localizing Your Rork Max Paywall with StoreKit 2
Localizing the paywall in the native app Rork Max generates: never hardcoding price, letting StoreKit 2's displayPrice handle every currency and locale, computing the yearly 'savings' so it never breaks across currencies, and handling the production drift from regional pricing and exchange-rate changes.
When my indie apps slowly started getting used abroad, the first thing that embarrassed me was the paywall. Built in Japan, it printed "480 yen / month" with the figure written directly into the string, so to a US user the currency symbol and the amount looked mismatched. The App Store holds a price per country, and my app's display was ignoring all of it.
Because Rork Max generates native Swift apps, billing is straightforward with StoreKit 2 — and used correctly, StoreKit lets Apple handle price display. This article starts from building a paywall that never hardcodes price, then covers computing the yearly "savings" so it never breaks per currency, and how to face the regional-price and exchange-rate drift you will always hit in production.
Why you must not write the price yourself
A StoreKit 2 Product already knows the price and currency for the user's storefront. product.displayPrice returns a fully formatted string for that region — "¥480", "$3.99", "€4,49" — notation, symbol, and digit grouping included. The moment you assemble it yourself as "$\(price)", you break European decimals (comma separators) and currency-symbol placement everywhere.
I got burned here once. Because I had hardcoded the price as a number, when Apple adjusted regional pricing, my app's display stayed stale — a dual-maintenance trap. The principle is singular: only ever display price strings that StoreKit returns.
Step 1: Load products and show displayPrice as-is
First fetch the products. Use the product IDs you configured in App Store Connect.
import StoreKit@MainActorfinal class PaywallModel: ObservableObject { @Published var products: [Product] = [] func load() async { do { // IDs defined in App Store Connect let ids = ["pro_monthly", "pro_yearly"] let fetched = try await Product.products(for: ids) // stable sort monthly → yearly products = fetched.sorted { $0.price < $1.price } } catch { print("product load failed: \(error)") // in production, surface a retry path in the UI } }}
On the display side, use displayPrice directly.
ForEach(model.products) { product in VStack(alignment: .leading) { Text(product.displayName) Text(product.displayPrice) // ← no manual formatting. this is the core of full-currency support .font(.title2).bold() }}
That displayPrice alone renders correctly whether viewed from yen, dollar, euro, or won. Not a single currency symbol appearing in your own code is the sign you wrote it right.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦A SwiftUI implementation that never hardcodes price and auto-supports every currency by showing StoreKit 2's displayPrice as-is
✦Concrete logic to compute and show how much cheaper yearly is than monthly without breaking across currencies or locales
✦How to dodge the production trap where regional pricing and exchange swings make users report 'the number is wrong,' with my own calls on handling it
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Step 2: Compute "how much % off" currency-independently
The classic subscription pitch is "yearly saves you N months" or "yearly is about N% off." Do not touch the price string here. Compute the discount from product.price (a Decimal number) and format the display separately.
extension PaywallModel { /// returns how many % cheaper yearly is than monthly × 12 func yearlyDiscountPercent(monthly: Product, yearly: Product) -> Int { let monthlyYear = monthly.price * 12 guard monthlyYear > 0 else { return 0 } let saved = (monthlyYear - yearly.price) / monthlyYear // Decimal → percent integer return Int((saved * 100 as Decimal).rounded(0, .down).description) ?? 0 }}
Because price is the currency's raw number, the ratio comes out of the same logic in yen or dollars. For monthly ¥480 / yearly ¥3,800 you get (480×12 − 3800) / (480×12) ≈ 34%; for US monthly $3.99 / yearly $29.99 you get (3.99×12 − 29.99) / (3.99×12) ≈ 37% — a discount based on each region's actual price. Write a fixed "30% off" and it becomes a lie the instant you adjust regional pricing.
Step 3: Keep the copy itself in localization files
Leave price to StoreKit, but localize the words — "save," "free trial," "cancel anytime" — in the app. Assembling price and copy into one string breaks when word order changes by language.
// use String Catalog (Localizable.xcstrings) keysText("paywall.yearly.save \(model.yearlyDiscountPercent(...))")// en: "Save %d%% with yearly"// ja: "年額で %d%% お得"
English and Japanese handle "%" and word order differently, so use a %d%% placeholder and inject only the number. I cut a corner here, padding a Japanese-first fixed string with a figure, and the English came out as the unnatural "34% Save yearly" — flagged in review.
Step 4: Free-trial availability also varies by region
Introductory offers (free trials or first-period discounts) come from product.subscription?.introductoryOffer in StoreKit 2. Because availability varies by region and the user's purchase history, never hardcode "7 days free."
if let intro = product.subscription?.introductoryOffer, intro.paymentMode == .freeTrial { let days = intro.period.localizedDays // derive the period from the StoreKit value too Text("paywall.trial \(days)") // ja: "%@日間無料でお試し"} else { Text("paywall.subscribe") // for users not eligible for a trial}
Showing "free" to a user not eligible for the trial (someone who already used it, say) leads to a "wasn't this free?" complaint and a refund after purchase. Always branch offer availability on real data.
When users say "the price doesn't match" in production
Occasionally after launch you'll get "the displayed price differs from what I was charged." I check the user's storefront (country/region) first. Most of the time, a VPN or their Apple ID country setting means they're seeing a different region's price than they think. Next I check whether Apple just revised regional pricing. When exchange rates move a lot, Apple updates its worldwide price table and the display changes. Your app is only emitting displayPrice, so this is not a "bug" — it's Apple's price revision reflected. Without this triage, you'll waste yourself suspecting the code.
On revenue design, I recommend supporting free features broadly with AdMob and keeping the subscription to a single axis bundling "remove ads + detailed features." Rather than slicing plans finely per currency, letting StoreKit handle price display and presenting a clean monthly/yearly choice produces, in my experience, less drop-off among overseas users.
Don't render an empty paywall on load failure
In production, what erodes revenue most isn't broken price formatting but "a paywall whose button vanished because products failed to load." Product.products(for:) can return an empty array on a transient network or store hiccup, and rendering that as-is wipes out the entire purchase path. I recommend auto-retrying once on first-load failure, and if it's still empty, showing a "failed to load · retry" state.
func loadWithRetry() async { await load() if products.isEmpty { try? await Task.sleep(for: .seconds(2)) await load() // a single retry resolves most transient failures }}
Unglamorous, but this one move noticeably improved my paywall's render-success rate and cut silent lost sales. Making the purchase screen "always fully render" matters to revenue as much as showing the price correctly.
Your next move
Open your current paywall code and hunt down every spot where a currency symbol or amount is written directly as a string. Replacing all of them with displayPrice and the discount computation expands your supported currencies worldwide at once. My own overseas share crept up after I finished this swap. The first step is deleting the hardcoded numbers from your code.
Share
Thank You for Reading
Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.