●DEADLINE — From August 31, every new app and update on Google Play must target Android 16 (API level 36). Six days to go●IOS27 — The seventh developer betas of iOS 27 and macOS 27 landed on August 24, with the public releases due next month. They include Apple Intelligence changes, so it is time to check your own builds●ANDROID17 — The Android 17 QPR1 beta fixed a bug that prevented swiping the bottom gesture bar to switch apps after using Circle to Search●FUNDING — On April 9, 2026, Rork announced a $15M seed round led by Left Lane Capital. It also acquired app builder Paperline and signalled it will keep acquiring to bring in engineering talent●TRAFFIC — Rork reports over 743,000 monthly visits with 85 percent growth, which suggests its narrow focus on native mobile is paying off among AI app builders●FORECAST — Gartner expects 75 percent of new applications to be built with low-code or no-code in 2026, up from under 25 percent in 2020●DEADLINE — From August 31, every new app and update on Google Play must target Android 16 (API level 36). Six days to go●IOS27 — The seventh developer betas of iOS 27 and macOS 27 landed on August 24, with the public releases due next month. They include Apple Intelligence changes, so it is time to check your own builds●ANDROID17 — The Android 17 QPR1 beta fixed a bug that prevented swiping the bottom gesture bar to switch apps after using Circle to Search●FUNDING — On April 9, 2026, Rork announced a $15M seed round led by Left Lane Capital. It also acquired app builder Paperline and signalled it will keep acquiring to bring in engineering talent●TRAFFIC — Rork reports over 743,000 monthly visits with 85 percent growth, which suggests its narrow focus on native mobile is paying off among AI app builders●FORECAST — Gartner expects 75 percent of new applications to be built with low-code or no-code in 2026, up from under 25 percent in 2020
I Split Rork and Claude Code by Who Owns the Build Environment
How to decide between Rork and a terminal coding agent using data from your own repository instead of impressions of generated code. Includes a script that counts which layer your maintenance work lands in, and a check that catches native settings silently disappearing on regeneration.
For a week I kept switching between the Rork interface and my own terminal. On one side, a generated screen appeared on a physical device within seconds. On the other, an agent wrote diffs into my local repository. Side by side, both moved at about the same speed. I spent most of a day trying to decide which one was better, and got nowhere.
The problem was that no evidence showed up. I read the generated screen code and I read the agent's diffs, and both sat comfortably inside "good enough." I ended the day without picking either.
The answer arrived only when I stopped comparing and started counting my own commit history instead. The places where I had actually been losing time turned out to sit in a different layer from the one generative AI is good at.
I Was Comparing the Wrong Thing
Ask "Rork or a terminal coding agent?" and the comparison naturally lands on code quality. Which one writes more readable JSX. Which one names things more sensibly. Which one puts state in the right place.
But even where a difference exists on that axis, it closes quickly in practice. If the code is hard to read, you fix it, and fixing it costs about the same in either environment.
The difference that does not close sits somewhere else entirely: whether you own the environment that builds your app and puts it on a device, or hand that off to someone else.
Hand it to Rork and you need no Mac, no Xcode, no Android Studio. Through Companion you can check on a real iPhone without a paid Apple Developer account. In exchange, on the day you need to reach one specific line inside ios/ or android/, you cannot reach it.
Keep it in-house and that line is yours. In exchange, SDK updates, certificate expiry, the compatibility matrix between Gradle and AGP — you maintain all of it, forever.
Neither is superior. The answer flips depending on how often your project actually touches the native layer. Having got that far, the next question was obvious: how often do I touch it?
Counting Which Layer the Work Lands In
Impressions were not producing an answer, so I counted from git history. The script below does nothing more than classify changed files by layer.
Here is what it prints, run against a small Expo project I set up to verify the script:
Files touched in the last 50 commits: 6
JS/TS layer 1 (16.7%)
Native config 2 (33.3%)
Config / plugins 2 (33.3%)
Assets 1 (16.7%)
Other 0 (0.0%)
Native-side landing rate: 66.7%
git log --name-only repeats the same file for every commit that touched it, so sort -u collapses them. The question is not "how many times did I touch this" but "how many distinct files did I have to touch." Counting occurrences lets the screen file you edit daily dominate the tally, and buries the build.gradle you open three times a year and lose half a day to each time.
When I pointed this at the apps I maintain as an indie developer, the result went against my own self-image. I think of myself as someone who writes JS/TS. The history says the config and native layers carry more of the work: new screen resolutions, build-tool version combinations, signing, App Store and Google Play declarations. None of that lives in screen code.
And screen code is exactly where generative AI is fastest. The layer where I lose time and the layer where generation helps are not the same layer. That mismatch is why the comparison had felt so slippery.
Here is how I read the resulting number:
Native-side landing rate
What it means
Suggested stance
Under 20%
Defaults are carrying you
Leave the build environment with the tool
20–50%
Settings change, but stay expressible as plugins
Generation primary, local secondary
Over 50%
Native work is the center of maintenance
Owning the build environment is worth considering
These thresholds are not universal. I use "reconsider above 50%" as my own marker, but the right number moves with the app. Anything carrying ads or in-app purchases is structurally heavier on the config side, while a read-only content app can sit comfortably in the teens.
✦
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 to switch tools using real data from your own repository, not your impression of the generated code
✦You will be able to catch native settings silently vanishing on regeneration before it reaches a submission
✦You will be able to draw the line feature by feature, rather than app by app, between what stays on the generated side and what you pull in-house
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.
Landing rate describes the past. For work that has not started yet, app.json gives you a preview. This script sorts configuration keys into "closes inside a generation prompt" and "requires agreement on the native side."
#!/usr/bin/env node// Read app.json / app.config.json and separate configuration keys into// "resolvable inside a generation prompt" and "requires native-side agreement."// Usage: node config-reach.mjs [path to app.json]import { readFileSync, existsSync } from "node:fs";const path = process.argv[2] ?? "app.json";if (!existsSync(path)) { console.error(`Config file not found: ${path}`); process.exit(1);}const expo = JSON.parse(readFileSync(path, "utf8")).expo ?? {};// Keys that require native-side agreement: signing, capabilities, review answersconst NATIVE_BOUND = { "ios.entitlements": "Requires enabling the capability and regenerating provisioning", "ios.infoPlist.UIBackgroundModes": "Declares background execution; you explain the use case at review", "ios.infoPlist.LSApplicationQueriesSchemes": "Pre-declares schemes you may query; capped at 50 entries", "ios.associatedDomains": "Only works paired with apple-app-site-association on your server", "android.permissions": "Propagates into runtime rationale and Play declaration forms", "android.blockedPermissions": "Cancels permissions a dependency injects on its own",};const get = (obj, dotted) => dotted.split(".").reduce((o, k) => (o == null ? undefined : o[k]), obj);const bound = [];for (const [key, why] of Object.entries(NATIVE_BOUND)) { const v = get(expo, key); if (v == null) continue; const count = Array.isArray(v) ? v.length : Object.keys(v).length; bound.push({ key, count, why });}// Purpose strings are a recurring rejection cause, so count them separatelyconst info = expo.ios?.infoPlist ?? {};const purposeStrings = Object.entries(info) .filter(([k]) => k.startsWith("NS") && k.endsWith("UsageDescription")) .map(([k, v]) => ({ key: k, length: String(v).length }));// A config plugin referenced by local path means you are editing native generationconst plugins = (expo.plugins ?? []).map((p) => (Array.isArray(p) ? p[0] : p));const localPlugins = plugins.filter((p) => typeof p === "string" && p.startsWith("."));console.log(`# Configuration reach for ${expo.name ?? path}\n`);console.log("## Requires native-side agreement");if (bound.length === 0) console.log(" none (everything closes inside generation)");for (const b of bound) console.log(` - ${b.key} (${b.count}) ... ${b.why}`);console.log("\n## Purpose strings");if (purposeStrings.length === 0) console.log(" none");for (const p of purposeStrings) { const flag = p.length < 20 ? " <- too short; state the use and the outcome" : ""; console.log(` - ${p.key} (${p.length} chars)${flag}`);}console.log("\n## Local config plugins");if (localPlugins.length === 0) console.log(" none");for (const p of localPlugins) console.log(` - ${p} ... verify it survives each regeneration`);const score = bound.length + localPlugins.length;console.log( `\nVerdict: ${score} concern(s) sitting on the native side.` + (score === 0 ? " Generation alone can carry this." : score <= 2 ? " Generation primary, native secondary works." : " Putting the native side first will cost you less rework."),);
Pointed at a project using background audio, HealthKit, and the photo library:
# Configuration reach for wallpaper-demo
## Requires native-side agreement
- ios.entitlements (1) ... Requires enabling the capability and regenerating provisioning
- ios.infoPlist.UIBackgroundModes (1) ... Declares background execution; you explain the use case at review
- ios.infoPlist.LSApplicationQueriesSchemes (2) ... Pre-declares schemes you may query; capped at 50 entries
- android.permissions (1) ... Propagates into runtime rationale and Play declaration forms
## Purpose strings
- NSPhotoLibraryAddUsageDescription (13 chars) <- too short; state the use and the outcome
## Local config plugins
- ./plugins/withAudioBackgroundMode ... verify it survives each regeneration
Verdict: 5 concern(s) sitting on the native side. Putting the native side first will cost you less rework.
The length check on purpose strings exists because short ones draw rejections. "Saves to your photo library" is a restatement of the API name. It never says why you collect it or what happens to the user, and automated review analysis flags exactly that gap. Twenty characters is a deliberately loose warning line; in practice I read each one and ask whether both the reason and the outcome are present.
The value here is less in the verdict line than in seeing the list and recognizing instantly that these items sit outside generation. Prompt for "add a feature that uses HealthKit" and the generator returns code. Enabling the capability and regenerating the provisioning profile stay on your side of the fence.
Pinning the Boundary So You Can Restore It
Once you decide to touch the native layer, the next thing you meet is regeneration. In Expo SDK 57, expo prebuild now discards and recreates ios/ and android/ by default. The line you added by hand disappears without a word.
The worst version of this is reaching submission without noticing, so I pin the diff as a patch and check whether it still applies.
#!/usr/bin/env bash# Pin hand-made native diffs as a patch and verify they still apply after regeneration.# Usage: ./pin-native.sh save ... save the current native diff to native.patch# ./pin-native.sh check ... report whether the saved patch applies (never applies it)set -euo pipefailPATCH="native.patch"BASE="${BASE_REF:-$(git rev-list --max-parents=0 HEAD | tail -1)}"PATHS=(ios android)case "${1:-}" in save) git diff "$BASE" -- "${PATHS[@]}" > "$PATCH" lines=$(grep -c '^[+-][^+-]' "$PATCH" || true) echo "Saved $PATCH (${lines:-0} changed lines)" echo "-> the closer this is to zero, the safer regeneration becomes" ;; check) [ -s "$PATCH" ] || { echo "Patch is empty; generation covers everything"; exit 0; } if git apply --check --reverse "$PATCH" 2>/dev/null; then echo "Already applied (the diff is present in the current tree)" elif git apply --check "$PATCH" 2>/dev/null; then echo "Not applied, but it still applies cleanly" else echo "Conflict: generation rewrote your native settings" echo "-> move the affected lines into a config plugin" exit 1 fi ;; *) echo "usage: $0 {save|check}"; exit 2 ;;esac
I verified all three states in a scratch repository. Right after saving, it reports "Already applied." After resetting the native directories to their initial state, it reports "Not applied, but it still applies cleanly." Once generation rewrites the same lines, it reports a conflict and exits 1.
--check is there because not applying is the point. Auto-applying leaves you unable to tell whether you overwrote the generator's change or preserved your own. Report whether it applies; let a human decide. That one restraint saves the entire session you would otherwise spend asking why a setting vanished.
BASE_REF is overridable because the first commit in a repository is rarely the same thing as "the state right after generation." The commit where you imported the generated output is the value you actually want to pass.
The changed-line count doubles as a risk gauge. Zero means regenerate freely. A handful means moving them into config plugins is realistic work. Several hundred means the honest conclusion is that the build environment already lives with you — take ios/ and android/ out of .gitignore and track them properly.
Three Ways the Measurement Goes Wrong
Taking the three scripts above into your own setup, these are the traps I hit or nearly hit. Each one quietly turns the numbers into a lie.
1. ios/ and android/ are still in .gitignore while you count
In the default Expo layout, native directories are treated as generated output and sit in .gitignore. Run native-landing.sh in that state and the native count comes back as zero. Reading a 0% landing rate as "generation covers everything" is the single most likely misreading of this article.
The fix is trivial — confirm tracking before you measure:
If they are ignored, read the config and plugin layer instead: the volume of change in app.json and plugins/. The native reality is merely absent from git; it is not absent from your week.
2. You apply the patch and then run check
pin-native.sh check exists to tell you whether the patch applies. Run it right after a manual git apply and it will report "already applied," which removes the only chance you had to detect a conflict. The order is regenerate, check, decide, apply. Reverse it and this check protects nothing.
I very nearly did this myself, and did not notice until I reread why the --reverse branch is evaluated first.
3. The same setting lives in both app.json and a config plugin
This is the normal state halfway through a migration. Writing it in both places is not an error; one simply wins. Which one wins is invisible until you inspect the built Info.plist, and it typically surfaces as behavior that differs between a local run and a distribution build.
Once you finish moving a setting, delete it from app.json. I recommend putting that cleanup in the same commit as the move — deferred to a later commit, it stays forever.
How I Actually Split It
Working from all of that, I divide by feature rather than by tool.
Kind of work
Where it lives
Why
Prototyping screens and flows
Rork side
Fastest path to something you can hold, and prototypes are meant to be thrown away
Visual polish and copy
Rork side
Quality here scales with how short the preview loop is
Settings tied to store declarations
In-house
Purpose strings and permission declarations belong next to the App Store review answers
Build-tool version alignment
In-house
Reproducing and isolating failures needs the full log
Maintaining shipped apps
In-house
The history of past decisions lives locally and cannot be detached from it
New work starts on the Rork side and gets pulled in-house once the checks above show native concerns accumulating. Since adopting that order, the deliberation has essentially disappeared.
The reverse direction — moving something in-house back onto the generated side — I have never once done. A shipped app is a pile of accumulated decisions, and that pile does not travel. Moving it would not be moving it; it would be rebuilding it.
What Surprised Me
I started out assuming more freedom simply meant a superset. If owning the build environment lets you do anything, then handing it off must be a convenience for beginners.
Counting taught me otherwise: the cost of owning it accrues on the days you do not use it. SDKs update on their own schedule, certificates expire on their own schedule, and build-tool combinations break on their own schedule. A project that opens ios/ three times a year still pays maintenance every month.
For a low-landing-rate project, that maintenance is waste. For a high-landing-rate project, it is proportionate to the actual work. That framing never surfaces while you are arguing about "freedom."
One more thing, about where generation speed lands. It is fast at writing screen code — a layer that was not costing me much time to begin with. What costs time is isolating why a build fails and decoding why review sent something back. Neither tool shortens that layer. What tool choice changes is only whether you can reach it at all.
Where to Start
Run native-landing.sh against your own project. It takes a couple of minutes, and the number will probably differ from what you expect.
That gap is the most reliable material you have for deciding whether to change tools. In my case it worked backwards into the arrangement I use now: new work on the generated side, maintenance in-house.
Your split will reasonably differ from mine, and there is no need to adopt this one wholesale. The counting procedure, though, works the same way in anyone's repository. Thanks for reading.
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.