●TOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13●OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude Code●SIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App Store●MAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessage●NATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core ML●SEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the round●TOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13●OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude Code●SIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App Store●MAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessage●NATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core ML●SEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the round
Ever looked at your AdMob iOS revenue dashboard and thought, "Android keeps climbing, but iOS eCPM is flat"? As an indie developer who has run ad-supported apps for years, I've found the root cause is almost always the same: App Tracking Transparency (ATT) opt-in rates are far lower than you expect. Industry data puts the average opt-in rate somewhere between 25% and 35%, and that number silently sets the ceiling on your iOS ad revenue.
Rork-generated apps are not exempt. If anything, the default AI-generated scaffolding tends to call the ATT prompt at a clumsy moment — or to skip the user-facing context entirely — so opt-in rates suffer. This article walks through the implementation patterns I reach for whenever I want to raise ATT consent on a Rork app, paired with the ad-serving strategy and the measurement setup that tell you whether any of it is actually working.
What ATT actually controls
Since iOS 14.5, ATT has been mandatory whenever your app uses IDFA to track users across other apps and websites. If the user taps "Ask App Not to Track," the IDFA returned by the OS becomes a zero-filled value (00000000-0000-0000-0000-000000000000), and ad networks like AdMob and Meta Audience Network can no longer deliver personalized ads based on behavioral signals.
The eCPM gap between personalized and non-personalized ads can be 2x to 4x in many verticals. That means moving opt-in from 30% to 60% often translates into roughly a 1.5x lift in total iOS ad revenue — which is why treating ATT as a copy-and-UX problem, not just a plumbing detail, matters. Adding more ad units on top of a leaking opt-in rate is pouring water into a bucket with a hole in it.
Configuring the purpose string in a Rork project
Start with the Info.plist side. Rork runs on Expo under the hood, so you declare the purpose string in app.json under ios.infoPlist:
{ "expo": { "ios": { "infoPlist": { "NSUserTrackingUsageDescription": "We use this only to tailor ads and recommend content you're likely to enjoy. No personally identifiable information is shared." } } }}
The content of the purpose string is what matters most. Apple's Human Interface Guidelines ask for a concrete purpose and a clear user benefit. I avoid the word "track" in favor of phrases like "tailor" and "recommend" — words the user reads from their own perspective rather than yours. One more small thing that has moved the needle for me: starting the string with a benefit rather than a justification. "We use this to..." phrasing reads as defensive, while a sentence that opens with what the user gets ("Tailor the ads and recommendations you see...") reads as inviting.
✦
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
✦The pre-permission screen design that lifted opt-in from 28% to 52%, plus the prompt-timing details that only reproduce on a real device
✦How to make opt-in measurable with cohort tracking, and the UMP (GDPR) plus ATT ordering that keeps the two prompts from colliding
✦A non-personalized ads fallback that keeps declining users earning, and the purpose-string wording that survives App Store review
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.
One detail that often slips past AI-generated scaffolds: the purpose string in NSUserTrackingUsageDescription needs to be localized for every App Store region your app ships to. Apple will surface your fallback (en) string to Japanese users if you forget to add a ja translation, and reviewers do occasionally flag the mismatch when an app's interface is fully Japanese but the ATT dialog is still English.
You can keep the Japanese and English strings side by side in Expo by using InfoPlist.strings files generated by a config plugin, or — for smaller projects — by maintaining a simple Localizable.strings under ios/<AppName>/ja.lproj/ after prebuilding. Either way, write the localized copy yourself rather than letting machine translation do it. ATT copy is short enough that every word carries weight, and a literal translation rarely lands as naturally as a re-written version.
Designing when to trigger the prompt
Timing is the single biggest implementation lever on opt-in rate. Prompting immediately on cold launch produces noticeably worse numbers than prompting after the user has experienced some value from the app.
Most Rork-generated scaffolds call the request at the top of App.tsx, and I recommend moving it. In the personal apps I ship, the prompt sits at one of these points:
Right after the user dismisses the final onboarding screen (new users)
Right after the first content interaction on the home screen (returning users)
On the second session — skipping the very first launch so the user can focus on the experience
Here is a minimal hook built on top of expo-tracking-transparency:
// hooks/useRequestTrackingPermission.tsimport * as TrackingTransparency from 'expo-tracking-transparency';import AsyncStorage from '@react-native-async-storage/async-storage';const STORAGE_KEY = 'att_requested_v1';/** * Fires the ATT prompt once and remembers that we asked. * Returns the current status on subsequent calls without reprompting. */export async function requestTrackingIfNeeded() { const already = await AsyncStorage.getItem(STORAGE_KEY); if (already === 'yes') { return TrackingTransparency.getTrackingPermissionsAsync(); } // Delay slightly so the OS dialog doesn't collide with a running transition await new Promise((resolve) => setTimeout(resolve, 300)); const result = await TrackingTransparency.requestTrackingPermissionsAsync(); await AsyncStorage.setItem(STORAGE_KEY, 'yes'); return result;}
The 300 ms delay matters: iOS occasionally swallows the ATT prompt when it's requested at the same instant as a screen transition. It's a small detail, but it shows up consistently on real devices.
Adding a pre-permission screen
The single most effective thing I've done for ATT opt-in is adding a "pre-permission" screen before the OS dialog. Instead of surfacing the OS prompt cold, I show a short in-app screen that explains — in plain language — why the next question is coming up, with a single "Continue" button that triggers the OS dialog.
The key is to not offer a "Deny" choice on your own screen. It competes with the OS dialog and confuses users. Treat this screen as a preview of what's about to happen, and leave the actual decision to the OS prompt.
// screens/TrackingPreExplanation.tsximport { View, Text, Pressable, StyleSheet } from 'react-native';import { requestTrackingIfNeeded } from '../hooks/useRequestTrackingPermission';export default function TrackingPreExplanation({ onDone }: { onDone: () => void }) { const handleContinue = async () => { await requestTrackingIfNeeded(); onDone(); }; return ( <View style={styles.container}> <Text style={styles.title}>Let's make this feel made for you</Text> <Text style={styles.body}> The next screen will ask a short question about ad personalization. Allowing it helps us show ads that match your interests, which is what lets us keep the app free to use. </Text> <Pressable style={styles.button} onPress={handleContinue}> <Text style={styles.buttonText}>Continue</Text> </Pressable> </View> );}const styles = StyleSheet.create({ container: { flex: 1, padding: 24, justifyContent: 'center' }, title: { fontSize: 24, fontWeight: '700', marginBottom: 16 }, body: { fontSize: 16, lineHeight: 24, color: '#333', marginBottom: 32 }, button: { backgroundColor: '#111', paddingVertical: 16, borderRadius: 12, alignItems: 'center' }, buttonText: { color: '#fff', fontSize: 16, fontWeight: '600' },});
Across the apps I've shipped this pattern in, opt-in moved from roughly 28% to 52%. Sample sizes are modest, but the shape of the lift has been consistent: a one-screen explanation of why changes the outcome a lot more than any other tweak I've tried.
Making opt-in measurable
Here is the part free articles rarely reach. Adding a pre-permission screen is only worthwhile if you can see its effect in numbers — otherwise you're guessing. The first thing I wire in is an event that records the final ATT status every time, tagged with how the user was asked. AdMob's own reporting won't give you this granularity, so I send a custom event to Firebase Analytics.
// analytics/trackAttOutcome.tsimport * as TrackingTransparency from 'expo-tracking-transparency';import analytics from '@react-native-firebase/analytics';/** * Record the ATT outcome together with which prompt variant produced it, * so opt-in rate can be compared across cohorts later. */export async function trackAttOutcome(promptVariant: 'pre_screen' | 'cold' | 'session2') { const { status } = await TrackingTransparency.getTrackingPermissionsAsync(); await analytics().logEvent('att_outcome', { status, // 'granted' | 'denied' | 'undetermined' | 'restricted' variant: promptVariant, // how the user was asked });}
Tagging the variant lets you line up the opt-in rate for "pre-permission screen" against "cold OS dialog" after the fact. With this in place, you can judge whether a copy rewrite or a timing change actually helped by looking at numbers instead of going on a hunch.
Go one step further and set the ATT status as a user property too, then join it against your ad-impression events. Now you can confirm, in your own app's real numbers, how much eCPM differs between the consented and declining cohorts. For me, seeing that chart was the moment the pre-permission screen stopped being a UI nicety and became a revenue lever — and that reframing is what moves it up the backlog.
UMP (GDPR consent) and ATT ordering
If you also serve the EU, you'll handle Google's UMP (User Messaging Platform) GDPR consent form alongside ATT. Get the order wrong and you'll see the consent form appear twice, or the consent state resolve after AdMob initializes — leaving ads non-personalized when they shouldn't be.
The order I rely on: refresh UMP consent info and show the form if required, resolve that, then request ATT, and only after both are settled call mobileAds().initialize().
// boot/consentBootstrap.tsimport mobileAds from 'react-native-google-mobile-ads';import { AdsConsent } from 'react-native-google-mobile-ads';import { requestTrackingIfNeeded } from '../hooks/useRequestTrackingPermission';export async function consentBootstrap() { // 1) Resolve GDPR (UMP) consent first const consentInfo = await AdsConsent.requestInfoUpdate(); if (consentInfo.isConsentFormAvailable && consentInfo.status === 'REQUIRED') { await AdsConsent.showForm(); } // 2) Then request ATT (only actually shown on iOS) await requestTrackingIfNeeded(); // 3) Initialize AdMob only after consent state is settled await mobileAds().initialize();}
Users who decline ATT still deserve ads — just non-personalized ones. With the React Native AdMob SDK (react-native-google-mobile-ads), you toggle the NPA (Non-Personalized Ads) flag dynamically based on the current permission status:
// ads/configureAdMob.tsimport mobileAds, { MaxAdContentRating } from 'react-native-google-mobile-ads';import * as TrackingTransparency from 'expo-tracking-transparency';export async function configureAdMob() { const { status } = await TrackingTransparency.getTrackingPermissionsAsync(); const npa = status === 'granted' ? '0' : '1'; await mobileAds().setRequestConfiguration({ maxAdContentRating: MaxAdContentRating.T, tagForChildDirectedTreatment: false, tagForUnderAgeOfConsent: false, }); await mobileAds().initialize(); // Pass this requestOptions to every ad unit you load return { requestOptions: { keywords: [], requestNonPersonalizedAdsOnly: npa === '1', }, };}
Flipping requestNonPersonalizedAdsOnly at runtime lets consented users see regular ads while declining users still see non-personalized ones. A common mistake is to short-circuit and show no ads at all to non-consenting users — that's revenue you're walking away from for no good reason. Pair this with the att_outcome event above so you can confirm the NPA fallback is actually earning the way you expect.
When not to call the prompt at all
Not every app needs to ask for ATT. If your monetization relies entirely on in-app purchases or subscriptions and you're not running any third-party ad networks or attribution SDKs, you don't need the IDFA at all — and prompting users only hurts trust. Apple even calls this out in the ATT documentation: apps should ask for tracking permission only when the IDFA is materially used.
The quick test I use: open package.json, search for SDKs that declare IDFA usage (AdMob, AppsFlyer, Adjust, Branch, Facebook, Unity Ads, and so on). If none are installed, skip the ATT prompt entirely and remove the purpose string from app.json. If your business model shifts later, you can add both back in a single release. "Implement it just in case" is the most wasteful choice here, in my experience.
Pitfalls you can't reproduce in the simulator
The ATT dialog does not appear in the iOS Simulator at all. Every piece of ATT testing has to happen on a real device. Before you hand a build to TestFlight testers, I work through these three manual checks on my dev phone:
Choose "Ask App Not to Track" on first launch, then confirm the ad is non-personalized
Change the permission in Settings → Privacy → Tracking, relaunch, and verify the app honors the new value
Switch the device language in Settings → General → Language & Region and confirm the purpose string appears in the localized form (when you ship a localized app.json)
The second check saves you from support tickets later. Users who decline and then change their mind often come back through Settings, and your app needs to respect that flip without a reinstall. If the dialog never appears in the first place, the ATT permission prompt not showing troubleshooting guide is the fastest way to isolate the cause.
Two review-rejection traps worth knowing
Two rejection patterns show up on most App Store reviews I've been through:
The first is an overly vague purpose string. Wording like "Used for analytics" tends to get flagged under Guideline 5.1.1. The app.json above sticks closer to concrete user benefit, which is what reviewers are checking for.
The second is leaving IDFA reads in the code path regardless of ATT status. Depending on the initialization order of legacy AdMob SDK versions and any third-party SDKs, the device may try to read IDFA on a declined user's phone. Apple's automated checks flag this. The rule of thumb: mobileAds().initialize() should run after the ATT request resolves. Rork's initial scaffolding sometimes flips this order, so it's worth confirming. For the broader privacy and disclosure picture, see my Rork privacy policy and App Store submission template and the AdMob mediation setup field notes. If your ads aren't displaying at all, the AdMob ads not showing troubleshooting guide is a faster path to resolution.
Start by rewriting one line of copy
ATT takes only a few lines of code to call, but the actual opt-in rate is shaped by the copy you show, the moment you show it, and whether you measure any of it — not the code itself. The smallest action you can take today is re-reading your app's NSUserTrackingUsageDescription and swapping the word "track" for "tailor" or "recommend." You'll see a difference on the next build. From there, layer in a pre-permission screen, add cohort-level measurement, and settle the UMP ordering, and you'll have an implementation foundation that defends your iOS ad revenue instead of leaking it. That's the order I built mine up in — I hope it helps if you're growing an ad-supported app 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.