Workshop Presentation
πOpen the slides β
We'll start the workshop with this presentation to cover the fundamentals before diving into code.
Your Hosts

Alexander Repnikov
Ruby on Rails Developer at Housecall Pro
Ruby developer and community organizer. Co-organized Ruby Warsaw Community Conference. Active contributor to the Ruby ecosystem with interests spanning from web development to compiler design.
About the Workshop
Hotwire Native lets you wrap your existing Rails web app in a native mobile shell, giving users a fully native experience on iOS and Android β without rewriting your app in Swift or Kotlin.
The philosophy is simple: βContent is all web. Navigation is all native.β Your server renders HTML as usual. The native shell handles navigation bars, gestures, transitions, and platform-specific UI elements.
In this workshop, we'll use a Kanban Board app built with Rails 8, Turbo Frames, Turbo Streams, Action Cable, and SortableJS drag-and-drop. The web app is fully functional β boards, columns, cards with real-time updates. Our job is to wrap it in native iOS and Android shells and progressively enhance it β adding Bridge Components for native UI elements and building fully native screens where they make sense.
Who is this for?
Rails developers curious about mobile. You don't need any iOS or Android experience. If you can build a Rails app with Hotwire, you already have 90% of the skills needed.
Pre-Workshop Setup
Please complete these steps before the workshop. Installing Xcode and Android Studio can take a while, so don't leave it for the morning of!
1. Install Ruby 3.4.8
If you don't have Ruby installed (or you're coming from a non-Ruby background like Next.js), follow one of the options below. We intentionally skip Windows β Rails development on Windows is painful. Use WSL2 with Ubuntu if you're on Windows.
macOS
First, install system dependencies:
Option A: rbenv (recommended)
rbenv auto-activates Ruby 3.4.8 when you cd into a project with .ruby-version.
Option B: rvm
Linux (Ubuntu/Debian)
First, install system dependencies:
Option A: rbenv (recommended)
Option B: rvm
Troubleshooting: Build fails on macOS? Run brew install openssl@3 readline libyaml gmp. rbenv version not found? Run brew upgrade ruby-build and retry. On Linux, make sure you have build-essential installed.
2. Rails Application
Requires SQLite 3 and Redis. Visit http://localhost:3000 to verify it works.
3. iOS Project (Xcode)
Requires Xcode 15+ (download from the Mac App Store). Open the project in Xcode, let it resolve Swift Package Manager dependencies, then build and run on an iPhone simulator.
4. Android Project (Android Studio)
Requires Android Studio (download from developer.android.com). Open the project, let Gradle sync dependencies, then run on an Android emulator. Works on macOS, Windows, and Linux.
Checklist before the workshop
- βRuby 3.4.8 installed (
ruby --version) - βRails app runs at
localhost:3000 - βXcode installed and iOS project builds on a simulator
- βAndroid Studio installed and Android project builds on an emulator
- βGit installed and repos cloned
What We'll Build
Each module builds on the previous one. If you don't finish everything during the workshop, use the code examples below to continue on your own.
1Introduction & Architecture
~20 minUnderstand how Hotwire Native works and why it exists. We'll look at the architecture, the Kanban Board app we'll be wrapping, and see how production apps like Basecamp and HEY Mail use it.
Introduction & Architecture
~20 minUnderstand how Hotwire Native works and why it exists. We'll look at the architecture, the Kanban Board app we'll be wrapping, and see how production apps like Basecamp and HEY Mail use it.
- β£The core philosophy: "Content is all web. Navigation is all native." β your server renders HTML, the native shell handles navigation bars, gestures, and transitions
- β£The three tiers of native integration: (1) pure web screens in a native shell, (2) Bridge Components that replace individual web elements with native ones, (3) fully native screens for platform APIs
- β£How it compares to React Native and Flutter β when a hybrid approach wins over full-native rewrites
- β£The Hotwire ecosystem: how Turbo, Stimulus, and Hotwire Native fit together
- β£Real-world case studies: Basecamp, HEY Mail, The StoryGraph (millions of users, small teams)
Outcome: You understand the architecture and can explain to your team why Hotwire Native might be the right choice for your project.
2First Launch β iOS & Android
~30 minGet the Kanban Board Rails app and both native shells running on simulators. This is the 'aha!' moment β your existing web app with boards, columns, and cards running inside a native shell with native navigation.
First Launch β iOS & Android
~30 minGet the Kanban Board Rails app and both native shells running on simulators. This is the 'aha!' moment β your existing web app with boards, columns, and cards running inside a native shell with native navigation.
- β£Start the Kanban Board Rails app (Turbo Frames, Turbo Streams, Action Cable, SortableJS) and verify it works in a browser
- β£Open the iOS project in Xcode β understand the project structure and Swift Package Manager dependencies
- β£The Navigator: the central object that manages navigation, sessions, and the back stack
- β£Open the Android project in Android Studio β Gradle setup and HotwireActivity
- β£Networking: localhost on iOS simulator vs. 10.0.2.2 on Android emulator
iOS entry point β SceneDelegate.swift
import HotwireNative
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
private let navigator = Navigator(
configuration: .init(
name: "main",
startLocation: URL(string: "http://localhost:3000")!
)
)
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options: UIScene.ConnectionOptions) {
guard let windowScene = scene as? UIWindowScene else { return }
window = UIWindow(windowScene: windowScene)
window?.rootViewController = navigator.rootViewController
window?.makeKeyAndVisible()
navigator.start()
}
}Android entry point β MainActivity.kt
import dev.hotwire.core.config.Hotwire
import dev.hotwire.navigation.activities.HotwireActivity
import dev.hotwire.navigation.navigator.NavigatorConfiguration
class MainActivity : HotwireActivity() {
override fun navigatorConfigurations() = listOf(
NavigatorConfiguration(
name = "main",
startLocation = "http://10.0.2.2:3000",
navigatorHostId = R.id.navigator_host
)
)
}Outcome: Both apps launch on simulators, showing the Kanban Board app with native navigation transitions β boards, columns, and drag-and-drop cards all working out of the box.
3Path Configuration
~30 minPath Configuration is the 'control plane' of Hotwire Native. It's a JSON file that maps URL patterns to native behaviors β deciding whether a screen should push, present as a modal, enable pull-to-refresh, and more. The best part: you can serve it remotely from your Rails app, so you can change routing behavior without an App Store release.
Path Configuration
~30 minPath Configuration is the 'control plane' of Hotwire Native. It's a JSON file that maps URL patterns to native behaviors β deciding whether a screen should push, present as a modal, enable pull-to-refresh, and more. The best part: you can serve it remotely from your Rails app, so you can change routing behavior without an App Store release.
- β£Structure: a JSON file with "settings" (global) and "rules" (URL pattern matching)
- β£Rules are processed top-to-bottom β later rules override earlier ones for the same URL
- β£Key properties: context (default/modal), presentation (push/pop/replace/refresh), pull_to_refresh_enabled
- β£Platform-specific properties: view_controller and modal_style (iOS), uri (Android)
- β£Local vs. remote path configuration β serve it from your Rails app for hot updates
path-configuration.json β controls how each URL pattern behaves
{
"settings": {},
"rules": [
{
"patterns": [".*"],
"properties": {
"context": "default",
"uri": "hotwire://fragment/web",
"pull_to_refresh_enabled": true
}
},
{
"patterns": ["/new$", "/edit$"],
"properties": {
"context": "modal",
"uri": "hotwire://fragment/web/modal/sheet",
"pull_to_refresh_enabled": false
}
},
{
"patterns": ["/boards$"],
"properties": {
"pull_to_refresh_enabled": true
}
}
]
}Serving path configuration from your Rails app
# config/routes.rb
get "/configurations/android_v1", to: "configurations#android"
get "/configurations/ios_v1", to: "configurations#ios"
# app/controllers/configurations_controller.rb
class ConfigurationsController < ApplicationController
def android
render json: Rails.root.join("config/android_v1.json").read
end
def ios
render json: Rails.root.join("config/ios_v1.json").read
end
endOutcome: Creating new cards and editing boards opens as native modal sheets, boards list supports pull-to-refresh β all controlled by a JSON file served from Rails.
4Adapting the Web UI
~20 minWhen your Rails app runs inside a native shell, you don't need web navigation bars, footers, or cookie banners. Rails provides helpers to detect the native context and conditionally render content.
Adapting the Web UI
~20 minWhen your Rails app runs inside a native shell, you don't need web navigation bars, footers, or cookie banners. Rails provides helpers to detect the native context and conditionally render content.
- β£The turbo_native_app? helper β detect if the request comes from a Hotwire Native app
- β£Hiding web-only chrome: navbars, footers, cookie banners, browser-specific CTAs
- β£Creating a dedicated layout for native app requests
- β£CSS adjustments: safe area insets, touch-friendly tap targets, viewport considerations
- β£Platform-specific rendering with User-Agent detection (iOS vs Android)
Conditional layout β hide web navigation in native apps
<!-- app/views/layouts/application.html.erb -->
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
<%= stylesheet_link_tag "application" %>
<%= javascript_include_tag "application", "data-turbo-track": "reload" %>
</head>
<body>
<% unless turbo_native_app? %>
<%= render "shared/navbar" %>
<% end %>
<main class="<%= turbo_native_app? ? 'native-app' : 'web-app' %>">
<%= yield %>
</main>
<% unless turbo_native_app? %>
<%= render "shared/footer" %>
<% end %>
</body>
</html>Native-specific styles
.native-app {
/* Remove extra padding that web navbar would need */
padding-top: 0;
/* Respect iOS safe areas (notch, home indicator) */
padding-bottom: env(safe-area-inset-bottom);
/* Larger touch targets for mobile */
--tap-target-size: 44px;
}Outcome: Your app looks clean inside the native shell β no duplicate navigation, proper spacing, mobile-optimized touch targets.
5Bridge Components
~45 minThis is the power feature of Hotwire Native. Bridge Components let you replace individual web UI elements with their native counterparts. For example, the Kanban Board's 'Save' button on card/board forms becomes a native toolbar button. The web side sends a message via a Stimulus controller, the native side renders a platform-native widget, and callbacks flow back to the web.
Bridge Components
~45 minThis is the power feature of Hotwire Native. Bridge Components let you replace individual web UI elements with their native counterparts. For example, the Kanban Board's 'Save' button on card/board forms becomes a native toolbar button. The web side sends a message via a Stimulus controller, the native side renders a platform-native widget, and callbacks flow back to the web.
- β£Architecture: a Stimulus-based BridgeComponent on the web paired with a native Swift/Kotlin counterpart
- β£BridgeElement: reads data-bridge-title, aria-label, or text content from your HTML
- β£The message flow: this.send(event, data, callback) from web to native, native triggers the callback
- β£data-bridge-* attributes for controlling behavior from HTML (disable per platform, custom titles)
- β£Building a complete bridge component end-to-end: web JS + Swift + Kotlin
Web side β form submit bridge component
// app/javascript/controllers/bridge/form_controller.js
import { BridgeComponent, BridgeElement } from "@hotwired/hotwire-native-bridge"
export default class extends BridgeComponent {
static component = "form"
static targets = ["submit"]
submitTargetConnected(target) {
const submitButton = new BridgeElement(target)
const submitTitle = submitButton.title
// Send message to native side with button title
// The callback fires when the native button is tapped
this.send("connect", { submitTitle }, () => {
target.click()
})
}
}HTML β annotate your form with bridge attributes
<%= form_with(model: @post) do |form| %>
<div data-controller="bridge--form">
<%= form.text_field :title, class: "form-input" %>
<%= form.text_area :body, class: "form-textarea" %>
<%= form.submit "Save Post",
data: {
bridge__form_target: "submit",
bridge_title: "Save"
},
class: "btn-primary" %>
</div>
<% end %>Native side β render a native toolbar button
import HotwireNative
import UIKit
final class FormComponent: BridgeComponent {
override class var name: String { "form" }
override func onReceive(message: Message) {
guard let event = Event(rawValue: message.event) else { return }
switch event {
case .connect:
handleConnect(message: message)
}
}
private func handleConnect(message: Message) {
guard let data: MessageData = message.data() else { return }
let action = UIAction { [unowned self] _ in
self.reply(to: "connect")
}
let item = UIBarButtonItem(
title: data.submitTitle,
primaryAction: action
)
viewController?.navigationItem.rightBarButtonItem = item
}
}
private extension FormComponent {
enum Event: String {
case connect
}
struct MessageData: Decodable {
let submitTitle: String
}
}Native side β render a native menu item
import dev.hotwire.core.bridge.BridgeComponent
import dev.hotwire.core.bridge.BridgeDelegate
import dev.hotwire.core.bridge.Message
class FormComponent(
name: String,
private val delegate: BridgeDelegate<HotwireDestination>
) : BridgeComponent<HotwireDestination>(name, delegate) {
override fun onReceive(message: Message) {
when (message.event) {
"connect" -> handleConnect(message)
}
}
private fun handleConnect(message: Message) {
val data = message.data<MessageData>() ?: return
// Add native toolbar menu item
delegate.destination.toolbarForNavigation()?.let { toolbar ->
toolbar.menu.clear()
toolbar.menu.add(data.submitTitle).apply {
setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS)
setOnMenuItemClickListener {
replyTo("connect")
true
}
}
}
}
@Serializable
data class MessageData(
@SerialName("submitTitle") val submitTitle: String
)
}Outcome: A web form's submit button is replaced with a native toolbar button on both platforms. Tapping it submits the web form. Users see a native UI, but the logic lives in your Rails app.
6Native Screens
~30 minSome features genuinely need platform APIs β maps, camera, Bluetooth, health data. For these, Hotwire Native lets you register fully native screens that activate for specific URL patterns. The native screen gets the full power of SwiftUI or Jetpack Compose while staying integrated with your app's navigation.
Native Screens
~30 minSome features genuinely need platform APIs β maps, camera, Bluetooth, health data. For these, Hotwire Native lets you register fully native screens that activate for specific URL patterns. The native screen gets the full power of SwiftUI or Jetpack Compose while staying integrated with your app's navigation.
- β£When to go native: the decision framework (web-first β bridge β native)
- β£Registering native screens via the path configuration view_controller (iOS) and uri (Android) properties
- β£Passing data from the web session to native screens
- β£Navigating back from native screens to web screens seamlessly
- β£Mixing native and web screens in the same navigation stack
Path configuration β route /map to a native screen
{
"patterns": ["/map$"],
"properties": {
"context": "default",
"uri": "hotwire://fragment/map",
"view_controller": "map"
}
}Native map screen with SwiftUI
import SwiftUI
import MapKit
import HotwireNative
struct MapView: View {
let location: CLLocationCoordinate2D
var body: some View {
Map(initialPosition: .region(
MKCoordinateRegion(
center: location,
span: MKCoordinateSpan(
latitudeDelta: 0.05,
longitudeDelta: 0.05
)
)
)) {
Marker("Location", coordinate: location)
}
.ignoresSafeArea()
}
}
// Register in your Navigator configuration:
// Hotwire.registerRouteDecisionHandler("map") { url in
// let mapVC = UIHostingController(rootView: MapView(...))
// return mapVC
// }Native map screen with Jetpack Compose
import dev.hotwire.navigation.fragments.HotwireFragment
import com.google.maps.android.compose.GoogleMap
import com.google.maps.android.compose.Marker
@AndroidEntryPoint
class MapFragment : HotwireFragment() {
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
return ComposeView(requireContext()).apply {
setContent {
GoogleMap(
modifier = Modifier.fillMaxSize()
) {
Marker(
state = MarkerState(position = LatLng(50.05, 19.93)),
title = "Workshop Location"
)
}
}
}
}
}
// Register in nav_graph.xml:
// <fragment android:name=".MapFragment"
// android:id="@+id/map_fragment" />Outcome: A specific URL in your app opens a fully native map screen instead of a web view. Users get native performance and platform APIs while the rest of the app stays web-based.
7Polishing & Next Steps
~20 minWrap up with navigation enhancements, error handling patterns, and a roadmap for taking your app to production β including tab bars, push notifications, and app store deployment.
Polishing & Next Steps
~20 minWrap up with navigation enhancements, error handling patterns, and a roadmap for taking your app to production β including tab bars, push notifications, and app store deployment.
- β£Adding a native tab bar with multiple Navigators (one per tab)
- β£Error handling: showing native error screens when the server is unreachable
- β£Authentication flows: handling sign-in/sign-out with cookies and sessions
- β£Push notifications overview: APNs (iOS) and FCM (Android)
- β£Deployment: TestFlight for beta testing, Google Play Console, CI/CD pipelines
Tab bar with multiple navigators
import HotwireNative
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
private let rootURL = URL(string: "https://your-app.dev")!
private lazy var homeNavigator = Navigator(
configuration: .init(name: "home", startLocation: rootURL)
)
private lazy var profileNavigator = Navigator(
configuration: .init(
name: "profile",
startLocation: rootURL.appendingPathComponent("/profile")
)
)
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options: UIScene.ConnectionOptions) {
guard let windowScene = scene as? UIWindowScene else { return }
let tabBar = UITabBarController()
homeNavigator.rootViewController.tabBarItem = UITabBarItem(
title: "Home",
image: UIImage(systemName: "house"),
tag: 0
)
profileNavigator.rootViewController.tabBarItem = UITabBarItem(
title: "Profile",
image: UIImage(systemName: "person"),
tag: 1
)
tabBar.viewControllers = [
homeNavigator.rootViewController,
profileNavigator.rootViewController
]
window = UIWindow(windowScene: windowScene)
window?.rootViewController = tabBar
window?.makeKeyAndVisible()
homeNavigator.start()
profileNavigator.start()
}
}Tab bar with multiple navigators
class MainActivity : HotwireActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val bottomNav = findViewById<BottomNavigationView>(R.id.bottom_navigation)
val homeNavigator = Navigator(
name = "home",
startLocation = "https://your-app.dev",
containerId = R.id.home_nav_host
)
val profileNavigator = Navigator(
name = "profile",
startLocation = "https://your-app.dev/profile",
containerId = R.id.profile_nav_host
)
bottomNav.setOnItemSelectedListener { item ->
when (item.itemId) {
R.id.nav_home -> {
showNavigator(homeNavigator)
true
}
R.id.nav_profile -> {
showNavigator(profileNavigator)
true
}
else -> false
}
}
homeNavigator.start()
profileNavigator.start()
}
}Outcome: You have a clear path from workshop prototype to production app β with tab navigation, error handling, and deployment strategy.
Resources
Official Documentation β
Hotwire Native docs β the primary reference
Workshop Rails Repo β
The Rails application we'll use during the workshop
Workshop iOS Repo β
iOS starter project with Hotwire Native
Workshop Android Repo β
Android starter project with Hotwire Native
Hotwire Native by Example β
Joe Masilotti's tutorial series β great for post-workshop learning
Ruby Community Conference β
Conference schedule, tickets, and other workshops
Part of Ruby Community Conference 2026
March 13, 2026 β Cracow, Poland. Workshops are free for conference ticket holders.
Get your conference ticket