Hotwire Native: Building Bridges

Engineering Insights

August 13, 2025
Dane Wilson
#
Min Read
Hotwire Native: Building Bridges

This is Part 2 in a series of posts designed to get you from Zero to Mobile Hero. In the first post, we discussed setting up our iOS and Android applications and linking them to our Ruby on Rails web app. In this post, we will add native components to enhance the look and feel of our native mobile applications using Hotwire’s BridgeComponent.

The bridge component is the star of the show for Hotwire Native. It uses data attributes on HTML elements and snippets of JavaScript to create native components that enhance the look and feel of your apps. Under the hood, Hotwire Native uses JavaScript to send data to and from the native application. If you’re already using Stimulus, you will feel very comfortable in creating Bridge Components because the APIs are very similar. For our first bridge component, let’s convert our “heart” button to a native button. On our show page, we have a button that allows a user to add a spot to their favorites. On mobile, users are accustomed to having this button in the navigation bar.

To get started, we need to add the hotwire-native-bridge package to your application. If you’re all no-build like me, you can use importmap /bin/importmap pin @hotwired/stimulus @hotwired/hotwire-native-bridge. Otherwise, you can enter yarn add @hotwired/hotwire-native-bridge to add the package to your package.json file. Please note that Stimulus is a requirement. Next, create a JavaScript file at app/javascript/bridge/heart_controller.js. Again, this file naming scheme should be familiar to Stimulus users.

import { BridgeComponent, BridgeElement } from "@hotwired/hotwire-native-bridge"

export default class extends BridgeComponent {
  static component = "heart"

  connect() {
    super.connect()
    this.notifyBridgeOfConnect()
  }

  notifyBridgeOfConnect() {
    const bridgeElement = new BridgeElement(this.element)
    const checked = bridgeElement.bridgeAttribute("checked") == "true"

    this.send("connect", { checked }, () => {
      this.element.click()
    })
  }
}

The above class defines the component as a static string. This string is used on the native side to identify the component. In the HTML file we define the controller by setting the data attribute "controller" to "bridge" followed by two hyphens ("-") then the name of the component. e.g. data-controller=”bridge–heart”. We will need to track the button state and we can do this using the same data attributes. We set the bridge element checked value by setting data-bridge-checked. Similarly, we can use the prefix data-bridge to set other variables and retrieve them via bridgeElement.bridgeAttribute("name-of-attribute").

<%# locals: (spot:, css_class: nil, mobile_component: false) %>
<% if spot.favorited_by?(current_user) %>
 <%= link_to spot_favorite_path(spot, current_user.favorite_for(spot)), class: "#{css_class} text-red-500 hover:text-gray-300 cursor-pointer btn-heart",
   method: :delete, data:{ turbo_method: :delete, controller: mobile_component ? "bridge--heart" : nil, bridge_checked: false } do %>
   <%= inline_svg_tag("unlike.svg") %>
 <% end %>
<% else %>
 <%= link_to spot_favorites_path(spot), class: "#{css_class} text-gray-300 hover:text-red-500 cursor-pointer btn-heart",
             method: :post, data: { turbo_method: :post, controller: mobile_component ? "bridge--heart" : nil, bridge_checked: false } do %>
   <%= inline_svg_tag("like.svg") %>
 <% end %>
<% end %>

The Android Bridge

Back in Android Studio, we need to add the corresponding HeartComponent. We can create a new package under our main directory called bridge. Then create the HeartComponent file.

class HeartComponent(
   name: String,
   private val delegate: BridgeDelegate<HotwireDestination>
) : BridgeComponent<HotwireDestination>(name, delegate) {

   private val actionButtonItemId = 15
   private var actionButtonMenuItem: MenuItem? = null
   private val fragment: Fragment
       get() = delegate.destination.fragment
   private val toolbar: Toolbar?
       get() = fragment.view?.findViewById(dev.hotwire.navigation.R.id.toolbar)

   override fun onReceive(message: Message) {
       // Handle incoming messages based on the message `event`.
       when (message.event) {
           "connect" -> handleConnectEvent(message)
           else -> Log.w("HeartComponent", "Unknown event for message: $message")
       }
   }

   private fun handleConnectEvent(message: Message) {
       val data = message.data<MessageData>() ?: return

       val menu = toolbar?.menu ?: return
       val inflater = LayoutInflater.from(fragment.requireContext())
       val binding = HeartComponentBinding.inflate(inflater)
       val order = 999 // Show as the right-most button
       val text = if (data.checked) "Remove Heart" else "Heart"

       binding.button.apply {
           isActivated = data.checked
           setOnClickListener {
               performSubmit()
           }
       }

       menu.removeItem(actionButtonItemId)
       actionButtonMenuItem = menu.add(Menu.NONE, actionButtonItemId, order, text).apply {
           actionView = binding.root
           setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS)
       }
   }

   private fun performSubmit(): Boolean {
       return replyTo("connect")
   }

   // Use kotlinx.serialization annotations to define a serializable
   // data class that represents the incoming message.data json.
   @Serializable
   data class MessageData(
       val checked: Boolean
   )
}

Our HeartComponent finds the Toolbar and adds the heart as the last icon to the right. When pressed the button sends a reply to the WebView which results in the button being clicked. If you’re paying attention, you’ll notice that we’re creating a binding with HeartComponentBinding which requires a heart_component.xml file in the res/layouts directory.

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="wrap_content"
    android:layout_height="match_parent"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_gravity="end|center_vertical"
    android:paddingEnd="16dp">

    <com.google.android.material.button.MaterialButton
        style="@style/Widget.Material3.Button.IconButton"
        android:background="?selectableItemBackgroundBorderless"
        android:id="@+id/button"
        android:layout_width="wrap_content"
        android:layout_height="48dp"
        app:icon="@drawable/ic_heart" />
</FrameLayout>

You’ll need to add a ic_heart.xml file to your drawables folder. The ones used in this example are from Google Fonts. To finalize our setup we need to add our component to the Hotwire Native configuration, and we do that by updating our Application class.

class SpotrApp: Application() {
    ..
    private fun configureApp() {
        ..
        Hotwire.registerBridgeComponents(
             BridgeComponentFactory("heart", ::HeartComponent)
        )
    }
}

Pat yourself on the shoulders, you’re a real Android Developer now! We can see our native button in our app and if you look closely you can still see the web button changing as the toolbar icon is pressed. We clean that up with a little css.

By default when your Hotwire Native application launches, a data-bridge-platform attribute is added to the HTML tag in this case it would be set to android. Also added is a data-bridge-components attribute which is a comma delimited list that contains all the components loaded by the Hotwire Native. We can use this information to remove the duplicate heart from our app.

[data-bridge-platform] {
  .btn-heart {
    display: none;
  }
}

The iOS Bridge

Now that we have our Android component out of the way, we can update our iOS app to use our bridge component. First, we need to create our HeartComponent file. I usually like to create a folder called Bridge then add my components there (Bridge/HeartComponent.swift).

import HotwireNative
import SwiftUI

public class HeartComponent: BridgeComponent {
    public override class var name: String { "heart" }
    
    public override func onReceive(message: Message) {
        guard let viewController else { return }
        addButton(via: message, to: viewController)
    }
    
    private var viewController: UIViewController? {
        delegate?.destination as? UIViewController
    }

    private func addButton(via message: Message, to viewController: UIViewController) {
        guard let data: MessageData = message.data() else { return }

        let action = UIAction { [unowned self] _ in
            self.reply(to: "connect")
        }
        
        let image = UIImage(systemName: data.checked ? "heart.fill" : "heart")
        let item = UIBarButtonItem(image: image, primaryAction: action)
        viewController.navigationItem.rightBarButtonItem = item
    }
}

private extension HeartComponent {
    struct MessageData: Decodable {
        let checked: Bool
    }
}

This adds the heart button to the right of the navigation bar. Rather than using Google Fonts for this iOS “heart” icon, we’re using SF Symbols which is Apple’s Native icon library for iOS and macOS and comes with Xcode. When pressed, it sends a reply to the WebView which results in the button being clicked.

Pro-tip: If you are running the app in a Simulator, you can open up Safari and go to Develop -> [Simulator] -> [App] to inspect the app's webview as you would in a browser.

Next, we need to add our component to the HotwireNative configuration. We do that by updating our AppDelegate file.

import HotwireNative

@main
class AppDelegate: UIResponder, UIApplicationDelegate {
    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        configureHotwire()
        return true
    }

    private func configureHotwire() {
        let localPathConfigURL = Bundle.main.url(forResource: "path-configuration", withExtension: "json")!
        let remotePathConfigURL = Spotr.current.appendingPathComponent("api/v1/configurations/ios.json")

        Hotwire.loadPathConfiguration(from: [
            .file(localPathConfigURL),
            .server(remotePathConfigURL)
        ])

        HotwireNative.registerBridgeComponents([
          HeartComponent.self
        ])

        // Set configuration options
        Hotwire.config.backButtonDisplayMode = .minimal
        Hotwire.config.showDoneButtonOnModals = true
#if DEBUG
        Hotwire.config.debugLoggingEnabled = true
#endif
    }
}

In our AppDelegate we register our HeartComponent with HotwireNative bridge components. We're also setting some configuration options for our app. The backButtonDisplayMode is set to .minimal which hides the back button text and only shows the back arrow. The showDoneButtonOnModals is set to true which shows a "Done" button on modals. Finally, we set debugLoggingEnabled to true which logs debug information to the console. You can remove this line when you're ready to ship your app.

Awesome! Now we have a native heart button on both Android and iOS. By now you should be able to see how easy it is to create BridgeComponents and should be thinking of all the other places you can use this in your app. In my own apps, I've used them to store push notification tokens, handle sharing, and even for authentication. I'll leave those as an exercise for you to explore. You can also try changing the primary color from the default blue to another color. In the next post, we'll take a look at how we can implement a native authentication flow with Hotwire Native. Until then, happy coding!

Related Insights

See All Articles
Engineering Insights
Hotwire Native: Building Bridges

Hotwire Native: Building Bridges

A three part series exploring development with Hotwire Native
Product Insights
Repo Roundup  August 4th, 2025

Repo Roundup August 4th, 2025

Weekly human curated list of new, interesting and noteworthy projects.
Engineering Insights
Hotwire Native: Zero to Mobile Hero

Hotwire Native: Zero to Mobile Hero

A three part series exploring development with Hotwire Native
Previous
Next
See All Articles