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 connect and asks the native app: "Is this user a subscriber?"
  • Native checks RevenueCat's customerInfo for active entitlements and a 2-hour rewarded ad-free timer
  • If non-subscriber: the card becomes visible with two CTAs
  • Subscribe button sends showPaywall to native, which presents RevenueCat's paywall
  • Watch Ad button sends showAd to 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_ID to your base Config.xcconfig (empty) and override it in each sport/target-specific xcconfig
  • Add the ADMOB_REWARDED_ID entry 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.