●SPLIT — Rork comes in two lines. The standard product generates React Native, while Rork Max, launched in February 2026, is a separate product that writes native Swift●MAX — Rork Max is reported at $200 a month, covering compilation on cloud Macs through App Store publishing, and reaching Vision Pro and iMessage●PLANS — The standard tiers run free, Junior at $25, Middle at $50, Senior at $100, with Scale above that. What gets counted is messages, not tokens●COUNT — How far a single message takes you depends heavily on how you write the prompt. The honest way to compare plans is to measure the messages an actual build took●STORE — From September 2026, App Store submissions and notarization requests require a new questionnaire that sets age ratings and Time Allowances behavior●SCOPE — Review requirement changes land on everything you have already shipped, not just the next release. Revisiting your submission checklist early is the safer move●SPLIT — Rork comes in two lines. The standard product generates React Native, while Rork Max, launched in February 2026, is a separate product that writes native Swift●MAX — Rork Max is reported at $200 a month, covering compilation on cloud Macs through App Store publishing, and reaching Vision Pro and iMessage●PLANS — The standard tiers run free, Junior at $25, Middle at $50, Senior at $100, with Scale above that. What gets counted is messages, not tokens●COUNT — How far a single message takes you depends heavily on how you write the prompt. The honest way to compare plans is to measure the messages an actual build took●STORE — From September 2026, App Store submissions and notarization requests require a new questionnaire that sets age ratings and Time Allowances behavior●SCOPE — Review requirement changes land on everything you have already shipped, not just the next release. Revisiting your submission checklist early is the safer move
Whether You Need Rork Max Is Answered by Your Permissions, Not the Price Page
When I cannot decide between standard Rork and Rork Max, I stop reading the price page. Instead I pull every OS capability my app actually asks for out of app.json and Info.plist, then sort them into what Expo covers, what a config plugin covers, and what truly needs native Swift.
I sat with the pricing page open for half an hour and could not decide. The difference in monthly cost was right there in front of me, and still nothing moved.
The reason surfaced later. I was not struggling to price Rork Max. I simply did not know what my own app was asking the operating system for. The material I needed to decide was not in anyone's comparison post. It was sitting in my project folder.
What blocked me was resolution, not price
At first I read comparison articles, one after another. It did not help much. Every one of them says "choose Max if you need native capabilities," which is true and also useless, because none of them can tell you whether your app needs native capabilities.
So I turned the question around. I moved the entry point from the product side to app.json and Info.plist. The permissions, background modes, and entitlements an app requests are already written down there. The document I needed had been in my hands the whole time.
Count before you choose — and count capabilities, not dollars. That order is the part I hold onto on the days when I feel least decisive.
Sorting everything into three columns
I kept the rubric small. Three columns, nothing more.
Column
Meaning
Examples
A
Reachable with plain React Native / Expo
Camera, saving photos, local notifications, biometrics, Sign in with Apple
B
Reachable with a config plugin or a little native configuration
Genuinely needs native Swift (out of reach for standard Rork)
HealthKit, HomeKit, Core NFC, Widgets, Live Activities, Screen Time API
Column B is the one people skip, and that is exactly why I keep it. If you fold "reachable once you write the config" into column C, you end up building a case for a migration you never needed. I very nearly did that myself.
The decision rule stays as short as the rubric. One item in column C is enough to justify looking at Max. An empty column C means the price conversation is no longer a capability conversation.
✦
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
✦You will be able to decide whether Rork Max is worth it from the OS capabilities your own app actually requests, instead of from how the monthly price feels
✦You will be able to run a short script over your own project that pulls permissions, background modes, and entitlements out of app.json and sorts them into three columns
✦You will be able to spot permission declarations you no longer use before you submit, which saves you the scramble of explaining them after review asks
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.
A small script so you are not reading plists by eye
Scanning by eye misses things. I handed the whole pass — read app.json, sort into three columns — to a short Node script that lives at the project root.
#!/usr/bin/env node// capability-inventory.mjs// Lists the OS capabilities an Expo project actually requests and sorts them into A/B/C.// Usage: node capability-inventory.mjs [project root]import { readFileSync, existsSync } from "node:fs";import { join } from "node:path";const root = process.argv[2] ?? ".";// Only things you cannot build without leaving Expo belong in column C.const RULES = [ ["UIBackgroundModes:audio", "B", "expo-av / expo-audio plus the background mode declaration"], ["UIBackgroundModes:remote-notification", "A", "within expo-notifications"], ["UIBackgroundModes:processing", "B", "BGTaskScheduler via expo-background-task"], ["UIBackgroundModes:location", "B", "needs expo-location background permission"], ["NSMicrophoneUsageDescription", "A", "recording works with expo-av / expo-audio"], ["NSCameraUsageDescription", "A", "within expo-camera"], ["NSPhotoLibraryAddUsageDescription", "A", "saving works with expo-media-library"], ["NSPhotoLibraryUsageDescription", "A", "within expo-media-library"], ["NSLocationWhenInUseUsageDescription", "A", "within expo-location"], ["NSFaceIDUsageDescription", "A", "within expo-local-authentication"], ["NSHealthShareUsageDescription", "C", "HealthKit lives outside Expo"], ["NSHomeKitUsageDescription", "C", "HomeKit lives outside Expo"], ["NFCReaderUsageDescription", "C", "Core NFC lives outside Expo"], ["LSApplicationQueriesSchemes", "A", "a declaration for Linking.canOpenURL"], ["aps-environment", "A", "push notification certificate setting"], ["com.apple.developer.applesignin", "A", "within expo-apple-authentication"], ["com.apple.developer.associated-domains", "B", "writable from a config plugin"], ["com.apple.developer.healthkit", "C", "HealthKit lives outside Expo"], ["com.apple.developer.homekit", "C", "HomeKit lives outside Expo"], ["com.apple.developer.usernotifications.time-sensitive", "B", "reachable through configuration"], ["com.apple.developer.group-session", "C", "SharePlay lives outside Expo"], ["com.apple.developer.family-controls", "C", "Screen Time API lives outside Expo"], ["widget", "C", "Widgets and Live Activities need a separate target"], ["ActivityKit", "C", "Live Activities need a separate target"],];function classify(key) { const hit = RULES.find((r) => key.toLowerCase().includes(r[0].toLowerCase())); return hit ? { col: hit[1], note: hit[2] } : { col: "?", note: "no rule yet — decide this one by hand" };}function readJson(path) { if (!existsSync(path)) return null; try { return JSON.parse(readFileSync(path, "utf8")); } catch (e) { console.error(`x could not read ${path}: ${e.message}`); return null; }}const appJson = readJson(join(root, "app.json")) ?? readJson(join(root, "app.config.json"));const pkg = readJson(join(root, "package.json"));if (!appJson) { console.error("No app.json here. If you use app.config.js, run"); console.error(" npx expo config --type public --json > app.json.tmp"); console.error("and point this script at that temporary file instead."); process.exit(1);}const expo = appJson.expo ?? appJson;const info = expo.ios?.infoPlist ?? {};const ent = expo.ios?.entitlements ?? {};const rows = [];// (1) Info.plist purpose strings. An empty value is a leftover declaration.for (const [k, v] of Object.entries(info)) { if (k === "UIBackgroundModes") { for (const mode of v ?? []) rows.push({ key: `UIBackgroundModes:${mode}`, source: "ios.infoPlist", empty: false }); continue; } const empty = typeof v === "string" ? v.trim() === "" : Array.isArray(v) ? v.length === 0 : false; rows.push({ key: k, source: "ios.infoPlist", empty });}// (2) entitlementsfor (const k of Object.keys(ent)) { rows.push({ key: k, source: "ios.entitlements", empty: false });}// (3) Android permissions, useful for cross-checking the iOS sidefor (const p of expo.android?.permissions ?? []) { rows.push({ key: p.replace("android.permission.", "ANDROID:"), source: "android.permissions", empty: false, });}const bucket = { A: [], B: [], C: [], "?": [] };for (const r of rows) { const { col, note } = r.source === "android.permissions" ? { col: "A", note: "declared on the Android side" } : classify(r.key); bucket[col].push({ ...r, note });}console.log(`# capability inventory — ${expo.name ?? "(no name set)"}`);console.log( `# ${rows.length} found / A ${bucket.A.length} · B ${bucket.B.length} · C ${bucket.C.length} · unsorted ${bucket["?"].length}\n`);for (const col of ["C", "B", "A", "?"]) { if (bucket[col].length === 0) continue; console.log(`[${col}]`); for (const r of bucket[col]) { const flag = r.empty ? " <- empty value; a candidate for removal" : ""; console.log(` ${r.key} (${r.source}) ${r.note}${flag}`); } console.log("");}if (pkg) { const natives = Object.keys(pkg.dependencies ?? {}).filter( (d) => d.startsWith("expo-") || d.startsWith("react-native-") || d === "react-native" ); console.log(`[reference] ${natives.length} dependencies that may carry native code`); console.log(" " + natives.join(", "));}// Exit 2 when column C has anything, so CI can notice.process.exit(bucket.C.length > 0 ? 2 : 0);
The rule table is not complete, and it does not need to be. I add a line whenever an unknown key shows up. The ? column is the interesting one anyway — those are the entries worth looking into yourself.
What it printed
Run against an app.json shaped like a small ambient-audio app:
# capability inventory — AmbientSleep# 10 found / A 9 · B 1 · C 0 · unsorted 0[B] UIBackgroundModes:audio (ios.infoPlist) expo-av / expo-audio plus the background mode declaration[A] UIBackgroundModes:remote-notification (ios.infoPlist) within expo-notifications NSMicrophoneUsageDescription (ios.infoPlist) recording works with expo-av / expo-audio NSPhotoLibraryAddUsageDescription (ios.infoPlist) saving works with expo-media-library NSCameraUsageDescription (ios.infoPlist) within expo-camera <- empty value; a candidate for removal LSApplicationQueriesSchemes (ios.infoPlist) a declaration for Linking.canOpenURL com.apple.developer.applesignin (ios.entitlements) within expo-apple-authentication aps-environment (ios.entitlements) push notification certificate setting ANDROID:POST_NOTIFICATIONS (android.permissions) declared on the Android side ANDROID:RECORD_AUDIO (android.permissions) declared on the Android side[reference] 5 dependencies that may carry native code expo-av, expo-notifications, react-native, react-native-google-mobile-ads, react-native-purchases
Column C is empty and the exit code is 0. Background playback, the item that looked most native to me, landed in B.
Point the same script at a project that reads sleep data and the shape changes:
# capability inventory — SleepCoach# 7 found / A 2 · B 3 · C 2 · unsorted 0[C] NSHealthShareUsageDescription (ios.infoPlist) HealthKit lives outside Expo com.apple.developer.healthkit (ios.entitlements) HealthKit lives outside Expo[B] UIBackgroundModes:audio (ios.infoPlist) expo-av / expo-audio plus the background mode declaration UIBackgroundModes:processing (ios.infoPlist) BGTaskScheduler via expo-background-task com.apple.developer.associated-domains (ios.entitlements) writable from a config plugin
Exit code 2. Only now has the pricing question earned its place.
Running the pass on my own apps left column C empty
As an indie developer I keep a wallpaper app, an ambient-audio app, and a small daily-intention app in the stores, and I ran the same inventory over them.
What came out was saving photos, local notifications, background playback, purchases, and the advertising identifier. Almost everything landed in A, with background playback alone sitting in B. Column C stayed empty. The capability I had assumed was out of reach turned out to live on the configuration side.
A second result ran against my expectations. What had cost me the most time was never the capability itself — it was the sentence explaining what the capability is for. Photos, microphone, either one: the code runs and the submission stalls. I wrote that up separately in Apple's automated pass reads what your purpose strings are for, not whether they exist. Changing the language your app is generated in moves that wall exactly zero.
And the largest group the inventory surfaced was declarations I was no longer using. Keys left behind by libraries I tried once, still sitting there with empty values. That is why the script marks them.
An empty column C means you are paying for speed, not capability
This is where the decision splits.
If column C has anything in it, you have three options: move to Max, drop the capability, or build that one piece some other way. In that case the price is a fair thing to compare, and I'd recommend re-estimating the monthly difference as a difference in hours rather than in features.
If column C is empty, what Max buys you is not capability. It is not needing a Mac on your desk, and having the simulator and the submission sit in one continuous flow — speed and logistics. Whether that is worth the monthly difference is not something a feature table can answer. I closed the comparison tab at this point.
Column B is not free, and that is where I slipped
After splitting things into three columns I underrated B. "Reachable once you write the config" still costs you time, and that was the first pitfall I walked into.
Implementing a B item usually means running npx expo prebuild and materialising ios/ and android/. Whether you commit those folders or throw them away and regenerate changes how the rest of your workflow feels. I went with regenerating, and moved every setting into a config plugin, because editing Info.plist by hand only means losing the edit at the next prebuild.
The workaround is plain enough. Anything expressible in app.json goes in app.json; anything else goes into a small config plugin. Those two steps absorb most of column B.
// plugins/with-background-audio.js// A minimal config plugin that adds UIBackgroundModes in a prebuild-safe way.const { withInfoPlist } = require("expo/config-plugins");module.exports = function withBackgroundAudio(config) { return withInfoPlist(config, (cfg) => { const modes = new Set(cfg.modResults.UIBackgroundModes ?? []); modes.add("audio"); cfg.modResults.UIBackgroundModes = [...modes]; return cfg; });};
// The declaration on the app.json side — just append to the array.{ "expo": { "plugins": ["./plugins/with-background-audio"] }}
This class of problem only surfaces in a real build, so I'd recommend pushing one eas build --profile preview through before you commit to a conclusion. Deciding "we clearly don't need Max" on an optimistic estimate of column B is how the workload surprises you later. It cost me half a day.
Three conditions that would still send me to Max
For my own use I kept the list to three.
Column C has an entry, especially something with no substitute such as HealthKit, Core NFC, or the Screen Time API
A separate target like a Widget or a Live Activity is central to the product rather than an extra
There is no Mac within reach and handing over the whole build environment is the point
If none of the three applies, I build on standard Rork and revisit the moment one capability falls out of reach. I have stopped buying the larger option first, just in case.
Write the decision down while you still remember why
Three months from now, the reasoning is gone. So I store the inventory result and the conclusion next to the code.
# docs/decisions/2026-09-08-rork-max.yamldate: 2026-09-08question: Move to Rork Max, or stay on standard Rork?inventory: a: 9 b: 1 c: 0 unknown: 0c_items: []decision: Stay on standard Rorkreason: | Column C is empty. Background playback landed in B (config plugin plus the background mode declaration). The monthly difference buys a build environment rather than a capability, and for now I would rather own that myself.revisit_when: - A Widget or Live Activity becomes central to the product - Several reviews actually ask for HealthKit integration
The revisit_when block is what saves you from redoing the whole analysis next time. It is the one field I try to write carefully.
A few things that tripped me
Treat the prices as secondary sources. Both the standard tiers and the Max monthly figure circulate mostly through roundups and review sites, and the wording shifted between the versions I checked. Reconcile against the rork.com pricing page before you decide anything. What the free allowance actually supports is covered in Rork's Free Tier Gives You 35 Credits a Month, but the Number That Shapes Your Work Is 5 a Day.
The billing unit matters as much as the inventory. You are charged per message, not per token — one prompt to the AI costs one credit. If you work by throwing many small corrections at it, your balance drops regardless of how heavy the feature is. For how the tiers compare, see Rork Pricing Compared: Free vs Pro vs Max.
If you use app.config.js, the script cannot read it directly. Export it once with npx expo config --type public --json and pass that file instead. The more dynamic your config, the more likely the export surprises you.
The inventory goes stale on every submission. One added library can add a declaration. I run the script as a single CI line and watch only the exit code, so the day column C sprouts something, I hear about it.
Wrapping up
Run the script once at the root of your own project and read only two things: the ? rows and the ones marked empty. Before you get anywhere near a pricing decision, you will probably find two or three declarations you can delete. That is where I started too.
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.