RORK LABJP
BUILD — Rork Max runs real Macs in the cloud loaded with Xcode and the iOS SDK, writing SwiftUI, compiling, reading the errors and building again. That loop, not the code generation, is what lifts the outputNATIVE — What comes out is pure Swift and SwiftUI, not React Native. Reaching AR, Metal graphics and widgets that React Native cannot touch is the real gap between this and other buildersPLATFORMS — Coverage spans iPhone, iPad, Apple Watch, Apple TV and Vision Pro, plus iMessage. Worth a look if you want to start from a watch app or an extension rather than a phone screenCOMPANION — The Rork Companion app lets you check a generated build on a real iPhone without a paid Apple Developer account, lowering the bar for trying a first project end to endPRICING — Free to start, paid plans from $25 a month, and Rork Max on the $200 Max plan. Worth working out up front how many projects it takes to earn that backDEADLINE — From August 31, 2026, Google Play requires target API level 36 or higher for new apps and updates alike. Ten days out, and the targetSdkVersion of what you generate is yours to verifyBUILD — Rork Max runs real Macs in the cloud loaded with Xcode and the iOS SDK, writing SwiftUI, compiling, reading the errors and building again. That loop, not the code generation, is what lifts the outputNATIVE — What comes out is pure Swift and SwiftUI, not React Native. Reaching AR, Metal graphics and widgets that React Native cannot touch is the real gap between this and other buildersPLATFORMS — Coverage spans iPhone, iPad, Apple Watch, Apple TV and Vision Pro, plus iMessage. Worth a look if you want to start from a watch app or an extension rather than a phone screenCOMPANION — The Rork Companion app lets you check a generated build on a real iPhone without a paid Apple Developer account, lowering the bar for trying a first project end to endPRICING — Free to start, paid plans from $25 a month, and Rork Max on the $200 Max plan. Worth working out up front how many projects it takes to earn that backDEADLINE — From August 31, 2026, Google Play requires target API level 36 or higher for new apps and updates alike. Ten days out, and the targetSdkVersion of what you generate is yours to verify
Articles/App Dev
App Dev/2026-08-21Beginner

Apple's automated pass reads what your purpose strings are for, not whether they exist

Submit a Rork or Expo iOS build and you may get it back under Guideline 5.1.1 with a note about placeholder or otherwise insufficient purpose strings. Here is how to read a rejection that names no key, and how to rewrite the strings from app.json.

App Store88Guideline 5.1.1Info.plist6purpose stringExpo176Rork540App Review3Privacy9

The morning after I submitted a wallpaper app update, App Store Connect had a red badge on it. Guideline 5.1.1, three lines of text. No key name. No file to open.

The build was fine. TestFlight had it. The previous update had gone through with nearly the same configuration.

The first hour of that day went entirely into figuring out what I had been told. If you are stuck in the same place, here is the path I took.

An automated analysis of the submission indicates the app includes placeholder or otherwise insufficient purpose strings

The message reads exactly like this:

An automated analysis of the submission indicates the app includes placeholder or otherwise insufficient purpose strings. Purpose strings must clearly and completely describe your use of data.

Two phrases carry the meaning.

The first is automated analysis. No reviewer opened your app. A machine read the Info.plist inside the binary you uploaded. Rejections with this wording started showing up in volume around July 20, 2026.

The second is placeholder or otherwise insufficient. Nothing here says the strings are missing. They are present, and their contents fall short.

That is what separates this from ITMS-90683: Missing Purpose String in Info.plist, which arrives during upload processing. One is absence, the other is inadequacy, and the fixes have nothing in common. If you are looking at the absence case instead, start with When your Rork iOS build gets rejected with ITMS-90683.

The awkward part is that the automated pass names no key. Which NS...UsageDescription tripped it is yours to work out.

Those default strings are not something you forgot to write

In an Expo project generated by Rork, adding a library that touches photos or the camera also adds the matching usage description for you. A mechanism called a config plugin writes it into the Info.plist at build time.

The default for expo-image-picker looks like this:

{
  "expo": {
    "plugins": [
      ["expo-image-picker", { "photosPermission": "Allow $(PRODUCT_NAME) to access your photos" }]
    ]
  }
}

Leave photosPermission unset and that sentence ships. $(PRODUCT_NAME) is substituted with your app name during the build, so the binary ends up carrying something like Allow Wallpaper Studio to access your photos.

It reads like English, which is precisely the problem. The field is not blank, so it feels handled. To an automated pass, it is a textbook placeholder.

What actually caught me was one step earlier than that.

The wallpaper app in question only saves generated images to Photos. It has no feature that reads the library at all, so NSPhotoLibraryAddUsageDescription should have been the only key present. The Info.plist also carried NSPhotoLibraryUsageDescription — the read permission — as a side effect of the library I had installed.

The automated pass also flags resources your app declares but never uses. However carefully you word a string, it cannot describe a use that does not exist. Before rewriting anything, I had to decide whether the key belonged there at all.

Look at what is actually in the Info.plist

Reading app.json will not tell you what finally gets written. Generate the native side once and inspect the real file.

npx expo prebuild --platform ios
grep -A1 "UsageDescription" ios/*/Info.plist

One caution. From Expo SDK 57 onward, expo prebuild discards and regenerates ios/ and android/ by default. If you have hand-edited anything in those directories, write down what you added before you run it. For a project still in the shape Rork generated, go ahead.

The output comes back looking like this:

	<key>NSPhotoLibraryUsageDescription</key>
	<string>Allow Wallpaper Studio to access your photos</string>
--
	<key>NSPhotoLibraryAddUsageDescription</key>
	<string>Used to save the wallpaper you selected into your Photos album…</string>
--
	<key>NSCameraUsageDescription</key>
	<string>Allow Wallpaper Studio to access your camera</string>

Knowing which library brings which key along makes the search much faster. These are the pairings I run into most often.

LibraryKeys it may inject
expo-image-pickerNSPhotoLibraryUsageDescription / NSPhotoLibraryAddUsageDescription / NSCameraUsageDescription / NSMicrophoneUsageDescription
expo-media-libraryNSPhotoLibraryUsageDescription / NSPhotoLibraryAddUsageDescription
expo-cameraNSCameraUsageDescription / NSMicrophoneUsageDescription
expo-locationNSLocationWhenInUseUsageDescription / NSLocationAlwaysAndWhenInUseUsageDescription
expo-tracking-transparencyNSUserTrackingUsageDescription
expo-local-authenticationNSFaceIDUsageDescription

Adding what you thought was only a photo picker can bring camera and microphone strings with it, because the library exposes both entry points. That is not a misconfiguration on your side. It still reads to the automated pass as a resource you declared and never use.

Some of these are obvious on sight. Others are a judgment call, and once the list grows past a handful, reading with your eyes starts missing things. I handed the judgment to a script.

Flag the defaults automatically

No dependencies, short enough to read in one sitting. It pulls every NS...UsageDescription out of the Info.plist and compares each value against the known default patterns.

import { readFileSync } from "node:fs";
 
// Strings Expo config plugins write by default, and that tend to survive to submission
const PLUGIN_DEFAULTS = [
  /^Allow .+ to access your photos$/i,
  /^Allow .+ to access your camera$/i,
  /^Allow .+ to access your microphone$/i,
  /^Allow .+ to use your location$/i,
  /^Allow .+ to access your (contacts|calendar|reminders)$/i,
  /^Allow .+ to save photos$/i,
];
 
// Sentences that state access without stating a reason
const NO_REASON = [
  /^(this app|the app) (needs|requires|uses) access to your [^.]+\.?$/i,
  /^(for|to) (better|improve) (user )?experience\.?$/i,
];
 
const plistPath = process.argv[2] ?? "ios/Info.plist";
const xml = readFileSync(plistPath, "utf8");
 
const pairs = [];
const re = /<key>(NS[A-Za-z]*UsageDescription)<\/key>\s*<string>([\s\S]*?)<\/string>/g;
let m;
while ((m = re.exec(xml)) !== null) {
  pairs.push({ key: m[1], value: m[2].trim() });
}
 
let flagged = 0;
for (const { key, value } of pairs) {
  const reasons = [];
  if (PLUGIN_DEFAULTS.some((r) => r.test(value))) reasons.push("plugin default, unchanged");
  if (NO_REASON.some((r) => r.test(value))) reasons.push("no stated purpose");
  if (value.length < 25) reasons.push("too short (under 25 characters)");
 
  if (reasons.length > 0) {
    flagged++;
    console.log(`FLAG ${key}`);
    console.log(`     "${value}"`);
    console.log(`     -> ${reasons.join(" / ")}`);
  } else {
    console.log(`OK   ${key}`);
  }
}
 
console.log(`\n${flagged} of ${pairs.length} strings need rewriting.`);
process.exit(flagged > 0 ? 1 : 0);

Run it with node audit-purpose-strings.mjs ios/YourApp/Info.plist. Against the Info.plist on my machine it printed this:

FLAG NSPhotoLibraryUsageDescription
     "Allow Wallpaper Studio to access your photos"
     -> plugin default, unchanged
OK   NSPhotoLibraryAddUsageDescription
FLAG NSCameraUsageDescription
     "Allow Wallpaper Studio to access your camera"
     -> plugin default, unchanged
FLAG NSMicrophoneUsageDescription
     "This app needs access to your microphone."
     -> no stated purpose
FLAG NSLocationWhenInUseUsageDescription
     "Allow Wallpaper Studio to use your location"
     -> plugin default, unchanged

4 of 6 strings need rewriting.

It exits 1 while anything is still flagged and 0 when the list is clean, so you can drop it straight into CI.

The 25-character floor is not a rule from Apple. It is roughly the length at which a sentence has room to say what the data is for, and it exists to make a human look again at whatever it catches.

Rewrite around resource, purpose, and boundary

Apple asks you to clearly and completely describe your use of data. Split that into three parts and you have a template.

KeyBeforeAfter
NSPhotoLibraryAddUsageDescription Allow $(PRODUCT_NAME) to access your photos Used to save the wallpaper you selected into your Photos album. The app never reads photos already in your library.
NSCameraUsageDescription Allow $(PRODUCT_NAME) to access your camera Used to take a profile photo without leaving the app. Photos you take are stored on this device only.
NSUserTrackingUsageDescription This app needs access to your data. Used only to limit how often the same ad is shown on this device. Browsing history is not collected or sold to third parties.

The shape is the same every time: which resource, for what purpose, and where it stops. That third clause does more work than it looks. Never reads, never uploads, never shares — there is usually something true and specific you can promise.

In app.json, the change looks like this:

{
  "expo": {
    "plugins": [
      ["expo-image-picker", {
        "photosPermission": false,
        "savePhotosPermission": "Used to save the wallpaper you selected into your Photos album. The app never reads photos already in your library.",
        "cameraPermission": false
      }]
    ]
  }
}

Passing false keeps that string out of the Info.plist entirely. For a resource your app does not use, the right answer is not a better sentence — it is removing the declaration.

There is one more trap if you ship in more than one language. app.json holds a single string per key; translations live in InfoPlist.strings files per locale. Rewrite the Japanese carefully, leave the English default in place, and the same rejection comes back the moment the app is checked in an English locale. That cost me an extra round trip once.

What needs rewriting is not the language App Review happens to use. It is every language you ship.

Send one line back with the resubmission

Once the corrected build is uploaded, I add a short note in the reply box in App Store Connect. There is no need to argue the case.

"Removed the unused photo library read declaration and replaced the save permission string with a specific description of its use."

Name what you changed and why, in one sentence, against what the automated pass reported. It saves the reviewer a lookup. Since I started writing it this way, the back-and-forth has settled in a single round.

Before your next submission, run audit-purpose-strings.mjs once. It finishes instantly, which is not a comparison you can make with the time spent hunting for a cause after a rejection lands.

If you want a wider set of pre-submission checks, The four acceptance checks I still run after the build turns green covers finding defects in the artifact itself. For rejections organized by guideline, A field manual for App Store rejections on Rork-built apps is the closer match.

For a long time I treated purpose strings as boxes to fill so a submission would pass. I think of them differently now: one line, in the user's hands, saying what the app does and where it stops.

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.

  • Copy-paste ready implementation code
  • New advanced guides published daily
  • $5/mo or $10 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

App Dev2026-06-25
The App Privacy Section That Grows the Moment You Add Ads and Subscriptions — Notes on What I Actually Checked
How I filled out App Store Connect's App Privacy section for a Rork (Expo) app with AdMob, RevenueCat, and Crashlytics — including the tracking-to-ATT chain, written up as field notes from running six apps.
Dev Tools2026-05-21
Rork iOS App Rejected with ITMS-90683 on TestFlight — How to Fix Missing Purpose Strings via app.json
If your Rork-built iOS app passes upload but gets an email titled ITMS-90683: Missing Purpose String in Info.plist, this guide walks through the real cause and the permanent fix via app.json, based on 12 years of shipping personal iOS apps with the same problem appearing across new SDK updates.
App Dev2026-08-18
The three places I had to fix before a Rork project actually targeted API level 36
My app.json said targetSdkVersion 36. The value my build actually read was 35. Here is the script that reports the effective value, and how I split my apps between raising, leaving alone, and requesting an extension.
📚RECOMMENDED BOOKS
Build a Large Language Model (From Scratch)
Sebastian Raschka
LLM Dev
Prompt Engineering for LLMs
Berryman & Ziegler
Prompting
AI Engineering
Chip Huyen
AI Eng
* Contains affiliate links
See all →