AdMob in a React Native app: placements, natural breaks and consent
How I run AdMob in two React Native apps: an ad unit per placement, interstitials only at natural breaks, rewarded trials, UMP consent and app-ads.txt.

On this page
Two of my seven apps show ads. Lockboxy, a private encrypted vault, shows interstitials and rewarded ads to free users. Linkeeper, a read-later app, shows only one kind of ad: an opt-in rewarded ad on a free-limit paywall. The other five have no ads at all.
Both apps use react-native-google-mobile-ads. The SDK part was quick. The work was in the rules: where an ad may appear, how often, what must happen before the first request, and what AdMob checks before it serves full inventory. Here is the setup in both repos, including two bugs that the tests never caught.
One port, and only one file that imports the SDK
Feature code never imports the ad SDK. It talks to a small AdsPort interface, and adMobAdsAdapter.ts is the only file that imports react-native-google-mobile-ads. Tests run against a fake port, and placement rules become enforceable:
export interface AdsPort {
requestConsent(): Promise<void>;
loadInterstitial(placement: InterstitialPlacement): Promise<void>;
showInterstitial(placement: InterstitialPlacement): Promise<boolean>; // false, never throws
loadRewarded(placement: RewardedPlacement): Promise<void>;
showRewarded(placement: RewardedPlacement): Promise<boolean>; // true only if the reward was earned
}Each app has a guard test that walks src/ and fails if a forbidden file imports the ads module. In Lockboxy that means the optional opening screen, the lock screen, onboarding and splash: no ad ever appears before you are inside the vault. In Linkeeper, only the paywall's useRewardedAd hook may touch ads.
An ad unit per placement
At first Lockboxy had two ad units, one interstitial and one rewarded, shared by every spot in the app. AdMob reports are per ad unit, so I couldn't tell which spot earned anything. In build 1.2.12 every placement got its own iOS unit, and every AdsPort call names its placement:
export const INTERSTITIAL_PLACEMENTS =
['lockNowHome', 'lockNowSettings', 'afterImport', 'afterSlideshow'] as const;
export const REWARDED_PLACEMENTS = ['unlockSlots', 'premiumTrial'] as const;
export function adUnitFor(kind, placement, platform = Platform.OS, dev = __DEV__) {
if (dev) return TEST_UNITS[platform][kind]; // Google's public test units
if (platform === 'android') return ORIGINAL_UNITS.android[kind];
const unit = IOS_PROD_UNITS[kind][placement];
return unit && unit !== IOS_UNIT_TODO ? unit : ORIGINAL_UNITS.ios[kind];
}Three details made this safe:
- Debug builds always get Google's test units. Tapping your own real ads during development can get an AdMob account banned, so the dev switch sits in the one function every request goes through.
- A placeholder can't ship. A placement whose unit hasn't been created yet holds
IOS_UNIT_TODO. At runtime it falls back to the original unit, so no bogus ID is ever requested.config.test.tsfails while any TODO remains, so a release can't go out with one. - The adapter keeps one loaded ad per unit, not per placement. Placements that share a unit (every Android placement, for now) share a slot, so the same unit is never loaded twice.
Linkeeper did the same from day one: three rewarded units, one each for extra imports, extra hidden-vault spots and an extra collection.
Interstitials only at natural breaks

Lockboxy's first interstitial sits on the manual Lock now button inside the main vault. You have finished what you came to do and you're leaving, so the ad doesn't cut into a task. In 1.2.12 I added two more natural breaks: back on the grid after importing at least 5 items, and when a slideshow of at least 30 seconds ends.
More placements could have meant more ads, so all of them go through one pure policy and share one lastShownAt:

The policy shows an ad only if all of these hold: the user is not Premium, they are in the main vault (never an extra vault), it isn't the first session, the install is at least two days old, at least four minutes have passed since any interstitial, and the ad is already loaded. A Lock now also has to be manual (never auto-lock, panic-lock or backgrounding) and the visit has to have lasted at least 20 seconds. If any check fails, nothing waits. The vault locks right away and a ad_interstitial_skipped {reason, placement} event records why.
The after-import ad also has to respect the screen. It shows about 0.9 s after the grid is back, never over the import summary or the "delete the originals?" question, and never while a confirm sheet is open.
The first version of this interstitial never showed once. Nothing ever called
loadInterstitial(), andshowInterstitial()only shows an ad that is already loaded. It was wired in July and fixed at the end of September; until then the AdMob console recorded zero requests. Tests passed the whole time because the policy and the adapter were each correct; the preload simply never happened. Now Home preloads the ad on every unlock into the main vault, and Settings preloads its own placement when it opens.
Rewarded ads that the user chooses
Rewarded ads are always opt-in, and each one has a reward that does something real:
- Lockboxy, +5 slots. Shown on the free-tier cap sheets, which preload the ad when they open so playback starts at once.
- Lockboxy, a 24-hour trial. On the paywall for Vault search, Web shortcuts or Intruder photo, "Watch an ad to try it for 24 hours" sits under the purchase button. One trial runs at a time, and each feature can be tried again only 7 days after its last trial started. I only made trialable the features that leave nothing half-built when the day ends. An extra vault or a recovery key would leave data locked behind a gate, so neither is on the list.
- Linkeeper, bigger free limits. +200 imports, +5 hidden-vault spots (ads can add at most +50) or +1 collection (at most +5). Never offered to Unlimited buyers, and never on the sync or settings paywalls.
The reward check matters more than it looks. showRewarded() resolves true only if EARNED_REWARD fired before CLOSED. A skip, an early dismiss, no fill or a load error all resolve false, so no reward is granted for an ad nobody finished.
Consent before the SDK, and honour the answer
Neither app shows the ATT prompt. Ad requests are non-personalized, and Google's UMP SDK handles the EEA/UK consent message. During Lockboxy's first submission, a NSUserTrackingUsageDescription key with no ATT call behind it held up review, so I removed the key instead of declaring tracking that doesn't happen.
The order is: consent first, then mobileAds().initialize(), then the first request. Both adapters enforce that order inside one memoized ensureInitialized(), so no caller has to sequence it:

async function ensureConsent(): Promise<boolean> {
try {
await AdsConsent.requestInfoUpdate();
await AdsConsent.loadAndShowConsentFormIfRequired();
} catch { /* offline, dismissed */ }
const ok = (await AdsConsent.getConsentInfo()).canRequestAds;
if (!ok) consentPromise = null; // run the flow again on the next ad call
return ok;
}The second bug was here, in both apps, and I fixed it on the same day. The flow ran, but nobody read its result. A failed or dismissed form, for example offline, went straight on to initialize() and an ad request. Worse, the failed promise stayed cached, so the form never appeared again. Now nothing loads unless canRequestAds is true, a failed flow falls back to consent stored in an earlier session, and a "no" retries on the next ad call.
app-ads.txt, the Marketing URL and the paperwork
Until AdMob has verified the app, serving is limited and the console shows "Requires review". Verification comes down to one line of text served from the app's own domain:
google.com, pub-XXXXXXXXXXXXXXXX, DIRECT, f08c47fec0942fa0Both landing sites serve it from public/app-ads.txt. The trap is which domain AdMob crawls. It isn't any domain you choose. It is the Marketing URL domain on the App Store listing. Linkeeper's file was live before that field pointed at https://linkeeper.io.vn, so the fix was to set the Marketing URL on the next version (1.3.0) and press "Check for updates" in AdMob. For Lockboxy, the AdMob app also had to be linked to its store listing, because the app was created while the iOS build was still waiting for review.
The rest of the checklist:
- Google's SKAdNetwork IDs in
Info.plist, next to the App ID. - App Store Connect App Privacy updated for AdMob: Device ID, Product Interaction, Advertising Data and Other Diagnostic Data for third-party advertising, not linked to identity, and not used for tracking.
Takeaways
- Put the SDK behind a port, and use a test to keep ads out of screens where they don't belong.
- Give each placement its own ad unit. Make the dev/test switch and the "no placeholder" check part of the code, not a release checklist item.
- Run every interstitial through one policy with one shared timestamp. Never block a user action while you wait for an ad.
- Read
canRequestAds. Running the consent flow isn't enough if you ignore its answer. - Serve
app-ads.txtfrom the Marketing URL's domain, then ask AdMob to check again.
The other half of the free tier, the paywall itself, is in RevenueCat paywall patterns. All seven apps are on apps.vanthuongdao.id.vn.
Related posts


