Monetize Your Hotwire Native App with Two Bridge Components
You have a Hotwire Native app. You want to monetize it. Subscriptions via RevenueCat, rewarded ads via AdMob. Both are native SDKs. Your web views can't touch them directly. Bridge components are the answer—they let your Rails-rendered buttons trigger native SDK calls without a single JavaScript API request.
Two bridge components. Your Rails app renders the UI. Your native app handles the money. I built this for Fitivity—800+ fitness apps with a mix of subscribers and ad-supported users. The pattern is generic enough to drop into any Hotwire Native project.
The Architecture
Here's the flow:
- Rails renders a promo card, hidden by default (
display: none) - A bridge component fires
connectand asks the native app: "Is this user a subscriber?" - Native checks RevenueCat's
customerInfofor active entitlements and a 2-hour rewarded ad-free timer - If non-subscriber: the card becomes visible with two CTAs
- Subscribe button sends
showPaywallto native, which presents RevenueCat's paywall - Watch Ad button sends
showAdto native, which presents a preloaded AdMob rewarded video - If the user watches the full ad, the native side grants a 2-hour ad-free period—banners disappear immediately
The key insight: your web side is display-only. It renders buttons and handles loading states. The native side handles every SDK call. No JavaScript API requests. No token passing. Just bridge messages.
The Web Side — Rails
Three files and a couple of registration lines. That's the entire web implementation.
1. Purchase Bridge Component
This Stimulus controller extends BridgeComponent to trigger RevenueCat's paywall from a web button:
// app/javascript/controllers/purchase_controller.js
import { BridgeComponent } from "@hotwired/hotwire-native-bridge"
export default class extends BridgeComponent {
static component = "purchase"
static targets = ["paywallButton", "restoreButton"]
showPaywall() {
console.log("Requesting paywall from native app...")
if (this.hasPaywallButtonTarget) {
this.paywallButtonTarget.disabled = true
}
this.send("showPaywall", {}, (message) => {
if (this.hasPaywallButtonTarget) {
this.paywallButtonTarget.disabled = false
}
if (message.data) {
const { success, message: responseMessage } = message.data
if (success) {
console.log("Paywall displayed successfully")
} else {
console.error("Failed to show paywall:", responseMessage)
}
}
})
}
restorePurchases() {
console.log("Requesting purchase restore from native app...")
if (this.hasRestoreButtonTarget) {
this.restoreButtonTarget.disabled = true
}
this.send("restorePurchases", {}, (message) => {
if (this.hasRestoreButtonTarget) {
this.restoreButtonTarget.disabled = false
}
if (message.data) {
const { success, message: responseMessage } = message.data
if (success) {
console.log("Restore completed:", responseMessage)
} else {
console.error("Restore failed:", responseMessage)
}
}
})
}
}
Straightforward.
showPaywall() sends a message to native, disables the button during the operation, re-enables on callback. restorePurchases() follows the same pattern. The native side decides what "show paywall" means—RevenueCat handles the rest.2. Rewarded Ad Bridge Component
This one does double duty. On
connect, it checks subscription status. If the user isn't a subscriber, it reveals the widget. The showAd() method triggers a rewarded video:// app/javascript/controllers/rewarded_ad_controller.js
import { BridgeComponent } from "@hotwired/hotwire-native-bridge"
export default class extends BridgeComponent {
static component = "rewarded-ad"
static targets = ["widget", "adButton", "adButtonText"]
static values = {
workoutAppId: Number
}
connect() {
super.connect()
// Ask native app for subscription status
this.send("connect", { workoutAppId: this.workoutAppIdValue }, (message) => {
if (message.data) {
const { isSubscriber } = message.data
if (!isSubscriber) {
// User is not a subscriber, reveal the widget
this.element.style.display = "block"
} else {
this.element.style.display = "none"
}
}
})
}
showAd() {
if (this.hasAdButtonTarget) {
this.adButtonTarget.disabled = true
if (this.hasAdButtonTextTarget) {
this.adButtonTextTarget.textContent = "Loading..."
}
}
this.send("showAd", { workoutAppId: this.workoutAppIdValue }, (message) => {
if (this.hasAdButtonTarget) {
this.adButtonTarget.disabled = false
if (this.hasAdButtonTextTarget) {
this.adButtonTextTarget.textContent = "Watch Ad for Free Session"
}
}
if (message.data) {
const { success, message: responseMessage } = message.data
if (success) {
console.log("Rewarded ad completed successfully")
} else {
console.error("Failed to show rewarded ad:", responseMessage)
}
}
})
}
}
The
connect() lifecycle is the clever part. The widget starts hidden. Native tells us whether to show it. No API call from JavaScript. No subscription check endpoint. The native app already knows the user's status via RevenueCat—just ask it.The
workoutAppId value is optional context you can pass to native. Replace it with whatever makes sense for your app—a product ID, a feature flag, whatever your native side needs to decide ad behavior.3. The Promo Card Partial
One ERB partial. Hidden by default. Both controllers attached:
<!-- app/views/shared/_ad_free_sales_widget.html.erb -->
<div data-controller="rewarded-ad purchase"
data-rewarded-ad-workout-app-id-value="<%= workout_app.id %>"
style="display:none;"
class="block relative rounded-xl overflow-hidden shadow-lg bg-white">
<div class="p-6">
<div class="mb-3">
<h3 class="text-gray-900 text-xl font-normal mb-1">Train Ad-Free.</h3>
<h3 class="text-indigo-600 text-xl font-bold">Go Premium.</h3>
</div>
<p class="text-gray-600 text-sm mb-6">
Remove ads and enjoy uninterrupted training sessions!
</p>
<div class="space-y-3">
<!-- Subscribe Button -->
<div data-controller="purchase">
<button data-action="purchase#showPaywall"
data-purchase-target="paywallButton"
class="w-full bg-indigo-600 hover:bg-indigo-700 text-white font-semibold py-3 px-4 rounded-lg transition-colors duration-200 uppercase text-sm">
Subscribe to Go Ad-Free
</button>
</div>
<!-- Watch Ad Button -->
<button data-action="rewarded-ad#showAd"
data-rewarded-ad-target="adButton"
class="w-full bg-indigo-600 hover:bg-indigo-700 text-white font-semibold py-3 px-4 rounded-lg transition-colors duration-200 uppercase text-sm">
<span data-rewarded-ad-target="adButtonText">Watch Ad for Free Session</span>
</button>
</div>
</div>
</div>
Note the
style="display:none;". This partial always renders in the HTML. The rewarded-ad controller's connect() reveals it only for non-subscribers. Subscribers never see it. No server-side subscription check needed.4. Register the Controllers
In your Stimulus controller index:
// app/javascript/controllers/index.js
import PurchaseController from "./purchase_controller"
application.register("purchase", PurchaseController)
import RewardedAdController from "./rewarded_ad_controller"
application.register("rewarded-ad", RewardedAdController)
5. Inject Into Your Feed
Drop the partial wherever you want it. We insert it after the first item in a list:
<% @training_programs.each_with_index do |training_program, index| %>
<!-- ... your card markup ... -->
<% if index == 0 %>
<%= render 'shared/ad_free_sales_widget', workout_app: @workout_app %>
<% end %>
<% end %>
Position it wherever feels natural in your UI. Between feed items, at the top of a settings page, after a workout completes. The widget is self-contained.
The Native Side — iOS (Swift)
Two bridge components in Swift. One for RevenueCat, one for AdMob plus subscription checking.
1. PurchaseComponent (RevenueCat)
This handles
showPaywall and restorePurchases messages from the web:import HotwireNative
import RevenueCat
import RevenueCatUI
import UIKit
final class PurchaseComponent: BridgeComponent {
override class var name: String { "purchase" }
override func onReceive(message: Message) {
switch message.event {
case "showPaywall":
handleShowPaywall(message: message)
case "restorePurchases":
handleRestorePurchases(message: message)
default:
break
}
}
private func handleShowPaywall(message: Message) {
guard let viewController = delegate.destination as? UIViewController else {
reply(to: "showPaywall", with: ["success": false, "message": "No view controller"])
return
}
let paywallVC = PaywallViewController()
paywallVC.delegate = self
let nav = UINavigationController(rootViewController: paywallVC)
viewController.present(nav, animated: true)
}
private func handleRestorePurchases(message: Message) {
Purchases.shared.restorePurchases { customerInfo, error in
if let error = error {
self.reply(to: "restorePurchases", with: [
"success": false,
"message": error.localizedDescription
])
} else {
let isActive = customerInfo?.entitlements["premium"]?.isActive == true
self.reply(to: "restorePurchases", with: [
"success": true,
"message": isActive ? "Premium restored!" : "No active subscription found."
])
}
}
}
}
extension PurchaseComponent: PaywallViewControllerDelegate {
func paywallViewController(_ controller: PaywallViewController,
didFinishPurchasingWith customerInfo: CustomerInfo) {
reply(to: "showPaywall", with: ["success": true])
}
}
RevenueCatUI gives you
PaywallViewController for free—a fully built paywall screen configured from your RevenueCat dashboard. Present it, handle the delegate callback, reply to the bridge. That's it.2. RewardedAdComponent (AdMob + Subscription Check)
This component does three things: checks subscription status on
connect, preloads an ad for instant presentation, and shows a rewarded video on showAd with a 2-hour ad-free reward:import HotwireNative
import UIKit
import GoogleMobileAds
final class RewardedAdComponent: BridgeComponent {
override class var name: String { "rewarded-ad" }
// Constants
fileprivate static let adFreeRewardDuration: TimeInterval = 7200 // 2 hours
// Ad state
fileprivate var rewardedAd: RewardedAd?
private var isLoadingAd = false
fileprivate var pendingShowAdMessage: Message?
fileprivate var userEarnedReward = false
private lazy var adContentDelegate = AdContentDelegate(component: self)
override func onReceive(message: Message) {
switch message.event {
case "connect":
handleConnect(message: message)
case "showAd":
handleShowAd(message: message)
default:
break
}
}
// MARK: - Connect Event
private func handleConnect(message: Message) {
// Check both permanent subscription and temporary ad-free timer
let isPremium = PaywallManager.shared.isPremiumUser()
let isAdFree = UserDefaults.standard.isRemoveAds
let isSubscriber = isPremium || isAdFree
reply(to: message.event, with: ConnectResponseData(isSubscriber: isSubscriber))
// Preload a rewarded ad for faster presentation when user taps
if !isSubscriber {
preloadRewardedAd()
}
}
// MARK: - Show Ad Event
private func handleShowAd(message: Message) {
// Re-check in case user subscribed since connect
let isPremium = PaywallManager.shared.isPremiumUser()
let isAdFree = UserDefaults.standard.isRemoveAds
if isPremium || isAdFree {
reply(to: message.event, with: ResponseData(
success: false,
message: "User already has ad-free access"
))
return
}
// Validate ad unit ID from xcconfig
let adUnitId = RuntimeConfiguration.AdMob.rewardedAdUnitId
guard !adUnitId.isEmpty else {
reply(to: message.event, with: ResponseData(
success: false,
message: "Rewarded ad unit ID not configured"
))
return
}
guard let viewController = self.viewController,
viewController.presentedViewController == nil else {
reply(to: message.event, with: ResponseData(
success: false,
message: "Another modal is currently presenting"
))
return
}
// If ad is preloaded, present immediately. Otherwise load on demand.
if let loadedAd = rewardedAd {
presentRewardedAd(loadedAd, from: viewController)
} else {
pendingShowAdMessage = message
loadRewardedAd(from: viewController)
}
}
// MARK: - Ad Loading
fileprivate func preloadRewardedAd() {
let adUnitId = RuntimeConfiguration.AdMob.rewardedAdUnitId
guard !adUnitId.isEmpty, rewardedAd == nil, !isLoadingAd else { return }
isLoadingAd = true
Task { @MainActor in
RewardedAd.load(with: adUnitId, request: Request()) { [weak self] ad, error in
guard let self = self else { return }
self.isLoadingAd = false
if let ad = ad {
self.rewardedAd = ad
ad.fullScreenContentDelegate = self.adContentDelegate
}
}
}
}
private func loadRewardedAd(from viewController: UIViewController) {
let adUnitId = RuntimeConfiguration.AdMob.rewardedAdUnitId
guard !adUnitId.isEmpty, !isLoadingAd else { return }
isLoadingAd = true
Task { @MainActor in
RewardedAd.load(with: adUnitId, request: Request()) { [weak self] ad, error in
guard let self = self else { return }
self.isLoadingAd = false
if let error = error {
self.reply(to: "showAd", with: ResponseData(
success: false,
message: "Failed to load ad: \(error.localizedDescription)"
))
self.pendingShowAdMessage = nil
return
}
if let ad = ad {
self.rewardedAd = ad
ad.fullScreenContentDelegate = self.adContentDelegate
self.presentRewardedAd(ad, from: viewController)
}
}
}
}
// MARK: - Ad Presentation
private func presentRewardedAd(_ ad: RewardedAd, from viewController: UIViewController) {
userEarnedReward = false
Task { @MainActor in
ad.present(from: viewController, userDidEarnRewardHandler: { [weak self] in
self?.userEarnedReward = true
})
}
}
}
Three things worth calling out:
Preloading. On
connect, if the user isn't a subscriber, we fire off preloadRewardedAd(). By the time they tap "Watch Ad," the video is already cached. No loading spinner, no delay. Without preloading, the user stares at "Loading..." for 2-5 seconds while Google fetches the ad creative. That's an eternity in UX terms.Earned vs. dismissed. Google's SDK distinguishes between "user watched enough to earn the reward" (
userDidEarnRewardHandler) and "user closed the ad" (adDidDismissFullScreenContent). We track this with userEarnedReward. The reward callback fires first, the dismiss callback fires second. We only grant ad-free time if the user actually earned it—closing the ad early means no reward.RuntimeConfiguration. The ad unit ID comes from
RuntimeConfiguration.AdMob.rewardedAdUnitId, not a hard-coded string. This matters when you have multiple targets (sport-specific apps, different ad units per target). More on this in the configuration section below.FullScreenContentDelegate — Handling Ad Lifecycle
The
AdContentDelegate is a separate class conforming to FullScreenContentDelegate. It handles what happens when the ad finishes—specifically, it's where the reward gets granted:private class AdContentDelegate: NSObject, FullScreenContentDelegate {
weak var component: RewardedAdComponent?
init(component: RewardedAdComponent) {
self.component = component
}
func ad(_ ad: any FullScreenPresentingAd,
didFailToPresentFullScreenContentWithError error: Error) {
guard let component = component else { return }
component.rewardedAd = nil
component.reply(to: "showAd", with: RewardedAdComponent.ResponseData(
success: false,
message: "Failed to present ad: \(error.localizedDescription)"
))
component.pendingShowAdMessage = nil
}
func adDidDismissFullScreenContent(_ ad: any FullScreenPresentingAd) {
guard let component = component else { return }
component.rewardedAd = nil
if component.userEarnedReward {
// Grant 2-hour ad-free period
let expiryDate = Date().addingTimeInterval(RewardedAdComponent.adFreeRewardDuration)
UserDefaults.rewardedAdFreeUntil = expiryDate
// Notify TabBarController to remove banners immediately
NotificationCenter.default.post(name: .subscriptionStatusChanged, object: nil)
component.reply(to: "showAd", with: RewardedAdComponent.ResponseData(
success: true,
message: "Enjoy 2 hours of ad-free experience!"
))
} else {
// User dismissed before earning reward
component.reply(to: "showAd", with: RewardedAdComponent.ResponseData(
success: false,
message: "Ad dismissed without earning reward"
))
}
component.pendingShowAdMessage = nil
component.preloadRewardedAd() // Preload next ad
}
}
The critical flow:
userDidEarnRewardHandler fires during the ad → sets flag → ad dismisses → adDidDismissFullScreenContent checks flag → grants 2-hour timer → posts .subscriptionStatusChanged notification → banners vanish. If the user closes early, the flag is false and nothing happens.Note the
preloadRewardedAd() call at the end. After the user watches (or dismisses) an ad, we immediately start loading the next one. If they want to watch another ad later, it's ready.3. Register Both Components
In your Hotwire Native setup (wherever you configure your
Navigator):Hotwire.registerBridgeComponents([
PurchaseComponent.self,
RewardedAdComponent.self,
// ... your other bridge components
])
Configuration — xcconfig to Swift
Hard-coding ad unit IDs in Swift is fine for a single app. But if you have multiple targets—different sport apps, white-label builds, staging vs. production—you need configuration that flows from build settings to runtime code.
Here's the four-layer chain: xcconfig → Info.plist → ConfigurationReader → RuntimeConfiguration.
Base Config.xcconfig
Your base xcconfig declares the key with an empty default. The comment tells future-you (or your team) where the real value comes from:
// Config.xcconfig — base configuration, shared across all targets DISPLAY_ADS = true ADMOB_APP_ID = ca-app-pub-5976599287520067~4588816385 // ADMOB_REWARDED_ID will be set per-sport in sport-specific .xcconfig files ADMOB_REWARDED_ID =
Sport-Specific Override
Each sport target has its own xcconfig that includes the base and overrides what it needs:
// Basketball.xcconfig #include "Config.xcconfig" APP_NAME = Basketball Training BUNDLE_IDENTIFIER = com.getfitivity.workouts.basketball APP_ID = 449 // AdMob - Production ad unit IDs for this sport ADMOB_APP_ID = ca-app-pub-5976599287520067~2490434521 ADMOB_BANNER_ID = ca-app-pub-5976599287520067/4539087134 ADMOB_REWARDED_ID = ca-app-pub-5976599287520067/1735085543
Info.plist Interpolation
In your Info.plist, reference the xcconfig variable with
$(VARIABLE_NAME) syntax:// Info.plist (relevant entries) <key>ADMOB_REWARDED_ID</key> <string>$(ADMOB_REWARDED_ID)</string>
Config.swift — Reading at Runtime
ConfigurationReader reads from the Info.plist bundle. Config wraps it in typed properties:class Config {
static let shared = Config()
// AdMob configuration (optional - only needed if displayAds = true)
var adMobRewardedID: String? = try? ConfigurationReader.value(for: "ADMOB_REWARDED_ID")
// ... other ad unit IDs ...
}
RuntimeConfiguration — The Access Point
RuntimeConfiguration.AdMob provides a clean namespace for all ad unit IDs. This is what your components call:struct RuntimeConfiguration {
struct AdMob {
static var rewardedAdUnitId: String {
Config.shared.adMobRewardedID ?? ""
}
static var bannerAdUnitId: String {
Config.shared.adMobBannerID ?? ""
}
// ... other ad unit IDs
}
}
Now
RuntimeConfiguration.AdMob.rewardedAdUnitId returns the correct ad unit for whichever target you're building. Basketball target gets Basketball's ad unit. Soccer target gets Soccer's. No #if conditionals. No environment switches. Just xcconfig inheritance.The 2-Hour Ad-Free Timer
The concrete reward for watching an ad is a 2-hour ad-free period. Here's how the pieces fit together.
UserDefaults Storage
The timer is just a
Date stored in UserDefaults:extension UserDefaults {
// Rewarded Ad-Free Timer
static var rewardedAdFreeUntil: Date? {
get { UserDefaults.standard.object(forKey: rewardedAdFreeUntilKey) as? Date }
set { UserDefaults.standard.setValue(newValue, forKey: rewardedAdFreeUntilKey) }
}
private static var rewardedAdFreeUntilKey = "REWARDED_AD_FREE_UNTIL"
}
isRemoveAds — The Single Source of Truth
Every piece of code that asks "should I show ads?" goes through one property:
extension UserDefaults {
var isRemoveAds: Bool {
// Check permanent ad removal flag (subscription)
if bool(forKey: "REMOVE_ADS") { return true }
// Check temporary rewarded ad-free timer
if let expiry = UserDefaults.rewardedAdFreeUntil {
return Date() < expiry
}
return false
}
}
This is the key abstraction.
isRemoveAds returns true for both permanent subscribers and users in a 2-hour ad-free window. The rest of your code doesn't need to know why ads are hidden—just whether they should be.Notification-Driven Banner Removal
When the timer is granted,
RewardedAdComponent posts a .subscriptionStatusChanged notification. TabBarController listens for it:extension Foundation.Notification.Name {
static let subscriptionStatusChanged = Foundation.Notification.Name("SubscriptionStatusChanged")
}
// In TabBarController
private func setupSubscriptionObserver() {
NotificationCenter.default.addObserver(
self,
selector: #selector(subscriptionStatusChanged),
name: .subscriptionStatusChanged,
object: nil
)
}
@objc private func subscriptionStatusChanged() {
if UserDefaults.standard.isRemoveAds {
// Remove all existing banners
for banner in viewControllerBanners.objectEnumerator()?.allObjects as? [LoyalBanner] ?? [] {
banner.removeFromParentView()
}
viewControllerBanners.removeAllObjects()
}
}
The moment the user earns the reward: timer is saved → notification fires →
TabBarController checks isRemoveAds → all banners are ripped out of the view hierarchy. Instant. The user sees banners disappear the second the rewarded video closes. That's the kind of immediate feedback that makes users want to watch another ad in 2 hours.Wire It Up
Checklist to get this running:
- Add RevenueCatUI and GoogleMobileAds SPM packages to your iOS project
- Configure RevenueCat with your API key in
AppDelegate(Purchases.configure(withAPIKey:)) - Create a rewarded ad unit in the AdMob console
- Add
ADMOB_REWARDED_IDto your baseConfig.xcconfig(empty) and override it in each sport/target-specific xcconfig - Add the
ADMOB_REWARDED_IDentry to your Info.plist with$(ADMOB_REWARDED_ID) - Create your paywall and entitlement ("premium") in the RevenueCat dashboard
- Register both bridge components in your native app
- Deploy the Rails partial and Stimulus controllers
- Submit an iOS update with the new native components
The Rails side deploys instantly—no app review needed. The native side requires an App Store update. Plan accordingly.
Ship It
This pattern works for any native SDK you want to trigger from web views. RevenueCat and AdMob today, StoreKit 2 or Unity Ads tomorrow. The bridge component abstraction means your web code doesn't care which SDK is on the other side.
If you're new to bridge components, check out how I use the same pattern for tab switching and stopping audio on navigation in Hotwire Native.
Bridge components are the Hotwire Native answer to "but I need native functionality." Your Rails app stays in charge of the UI. Your native app stays in charge of the platform. Two components, one partial, zero JavaScript API calls.
The whole web side is about 200 lines across three files. The native side is two Swift files plus a few lines of configuration. And your users get a seamless monetization experience that feels completely native—because half of it is.
Been wanting to add subscriptions or ads to your Hotwire Native app but not sure where to start? Email me at jonathan@rubygrowthlabs.com with the subject "Hotwire monetization" and tell me what you're building.