Ruby Community Conference logo
Ruby Community Conference 2026/Workshop

Hotwire NativeBuild Mobile Apps the Rails Way

A hands-on workshop where you'll wrap a Kanban Board Rails app into native iOS and Android apps. No prior mobile development experience required.

πŸ“…
When
March 13, 2026, 10:00
πŸ“
Where
Archeion, ul. sw. Filipa 23/6
sala PLATONA, Cracow
⏱
Duration
~3-4 hours
πŸ‘₯
Seats
12 participants
SlidesYour HostsAbout the WorkshopPre-Workshop SetupWhat We'll BuildResources

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

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.

LinkedIn GitHub
Sebastian Tekieli

Sebastian Tekieli

Technical Project Manager at Visuality

20+ years building and scaling web and SaaS applications. Former CTO at Punkta and DataFeedWatch. Passionate about Rails, AI agents, and shipping products fast.

LinkedIn GitHub

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:

$brew install openssl@3 readline libyaml gmp

Option A: rbenv (recommended)

# Install rbenv
$brew install rbenv ruby-build
Β 
# Add to ~/.zshrc:
$echo 'eval "$(rbenv init - zsh)"' >> ~/.zshrc
$source ~/.zshrc
Β 
# Install Ruby
$rbenv install 3.4.8
$ruby --version # should show: ruby 3.4.8

rbenv auto-activates Ruby 3.4.8 when you cd into a project with .ruby-version.

Option B: rvm

# Import GPG keys
$brew install gnupg
$gpg --keyserver keyserver.ubuntu.com --recv-keys \
409B6B1796C275462A1703113804BB82D39DC0E3 \
7D2BAF1CF37B13E2069D6956105BD0E739499BDB
Β 
# Install rvm
$\curl -sSL https://get.rvm.io | bash -s stable
$source ~/.rvm/scripts/rvm
Β 
# Install Ruby (Apple Silicon needs explicit OpenSSL path)
$rvm install ruby-3.4.8 --with-openssl-dir=$(brew --prefix)/opt/openssl@3
$rvm --default use 3.4.8
$ruby --version # should show: ruby 3.4.8
Linux (Ubuntu/Debian)

First, install system dependencies:

$sudo apt update
$sudo apt install -y build-essential libssl-dev libreadline-dev \
zlib1g-dev libyaml-dev libgmp-dev libffi-dev libsqlite3-dev

Option A: rbenv (recommended)

# Install rbenv
$git clone https://github.com/rbenv/rbenv.git ~/.rbenv
$echo 'export PATH="$HOME/.rbenv/bin:$PATH"' >> ~/.bashrc
$echo 'eval "$(rbenv init -)"' >> ~/.bashrc
$source ~/.bashrc
Β 
# Install ruby-build plugin
$git clone https://github.com/rbenv/ruby-build.git ~/.rbenv/plugins/ruby-build
Β 
# Install Ruby
$rbenv install 3.4.8
$ruby --version # should show: ruby 3.4.8

Option B: rvm

# Import GPG keys
$gpg --keyserver keyserver.ubuntu.com --recv-keys \
409B6B1796C275462A1703113804BB82D39DC0E3 \
7D2BAF1CF37B13E2069D6956105BD0E739499BDB
Β 
# Install rvm
$\curl -sSL https://get.rvm.io | bash -s stable
$source ~/.rvm/scripts/rvm
Β 
# Install Ruby
$rvm install ruby-3.4.8
$rvm --default use 3.4.8
$ruby --version # should show: ruby 3.4.8

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

$git clone https://github.com/arepnikov/hotwire-native-workshop-rails.git
$cd hotwire-native-workshop-rails
$bundle install
$rails db:setup
$bin/dev

Requires SQLite 3 and Redis. Visit http://localhost:3000 to verify it works.

3. iOS Project (Xcode)

$git clone https://github.com/arepnikov/hotwire-native-workshop-ios.git

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)

$git clone https://github.com/arepnikov/hotwire-native-workshop-android.git

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.

1

Introduction & Architecture

~20 min

Understand 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.

2

First Launch β€” iOS & Android

~30 min

Get 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

Swift (iOS)
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

Kotlin (Android)
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.

3

Path Configuration

~30 min

Path 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

JSON
{
  "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

Ruby (Rails)
# 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
end

Outcome: 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.

4

Adapting the Web UI

~20 min

When 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

ERB (Rails)
<!-- 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

CSS
.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.

5

Bridge Components

~45 min

This 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

JavaScript (Stimulus)
// 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

ERB
<%= 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

Swift (iOS)
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

Kotlin (Android)
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.

6

Native Screens

~30 min

Some 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

JSON
{
  "patterns": ["/map$"],
  "properties": {
    "context": "default",
    "uri": "hotwire://fragment/map",
    "view_controller": "map"
  }
}

Native map screen with SwiftUI

Swift (iOS)
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

Kotlin (Android)
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.

7

Polishing & Next Steps

~20 min

Wrap 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

Swift (iOS)
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

Kotlin (Android)
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
Hotwire Native Workshop Β· Ruby Community Conference 2026 Β· Organized by Visuality