Ruby Community ConferenceRuby Community Conference 2026

Hotwire Native

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

SlidesYour HostsAbout the WorkshopPre-Workshop SetupWhat We'll BuildResources
πŸ“Š

Workshop Presentation

We'll start the workshop with this presentation to cover the fundamentals before diving into code.

Open the Slides β†—
Your Hosts

Meet the Instructors

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 β†—
Alexander Repnikov

Alexander Repnikov

Ruby on Rails Developer at Housecall Pro

Ruby Association Certified Programmer with 10+ years experience in Ruby on Rails, open-source contributor, co-organizer of Ruby Warsaw Community Conference.

LinkedIn β†—GitHub β†—
About the Workshop

Hotwire Native, Explained

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.

During the workshop you'll build around a Kanban Board app β€” built with Rails 8, Turbo Frames, Turbo Streams, Action Cable, and SortableJS drag-and-drop. The web app features boards, columns, and cards with real-time updates. We'll progressively wrap it in native iOS and Android shells, enhance it with Bridge Components, and add fully native screens.

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

Get Ready Before You Arrive

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!

1Install Ruby 3.4.8
macOS
First, install system dependencies
brew install openssl@3 readline libyaml gmp
Option A: rbenv (recommended)
brew install rbenv ruby-build
echo 'eval "$(rbenv init - zsh)"' >> ~/.zshrc
source ~/.zshrc
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
brew install gnupg
gpg --keyserver keyserver.ubuntu.com --recv-keys \
  409B6B1796C275462A1703113804BB82D39DC0E3 \
  7D2BAF1CF37B13E2069D6956105BD0E739499BDB

\curl -sSL https://get.rvm.io | bash -s stable
source ~/.rvm/scripts/rvm

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)
git clone https://github.com/rbenv/rbenv.git ~/.rbenv
echo 'export PATH="$HOME/.rbenv/bin:$PATH"' >> ~/.bashrc
echo 'eval "$(rbenv init -)"' >> ~/.bashrc
source ~/.bashrc

git clone https://github.com/rbenv/ruby-build.git ~/.rbenv/plugins/ruby-build

rbenv install 3.4.8
ruby --version # should show: ruby 3.4.8
Option B: rvm
gpg --keyserver keyserver.ubuntu.com --recv-keys \
  409B6B1796C275462A1703113804BB82D39DC0E3 \
  7D2BAF1CF37B13E2069D6956105BD0E739499BDB

\curl -sSL https://get.rvm.io | bash -s stable
source ~/.rvm/scripts/rvm

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
2Clone & Run the Rails Application

Requires SQLite 3 and Redis.

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

Visit http://localhost:3000 to verify it works.

3Set Up the iOS Project (Xcode)

Requires Xcode 15+ β€” download from the Mac App Store.

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

Open the project in Xcode, let it resolve Swift Package Manager dependencies, then build and run on an iPhone simulator.

4Set Up the Android Project (Android Studio)

Requires Android Studio β€” download from developer.android.com. Works on macOS, Windows, and Linux.

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

Open the project, let Gradle sync dependencies, then run on an Android emulator.

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 all repos cloned
Workshop Modules

What We'll Build

Four modules, each building on the previous one. Follow along step by step.

1Base: MainActivity & Native Title

App Entry Point

android Open java/com/example/hotwirenativeworkshop/main/MainActivity.kt.

Here we configure the backend endpoint:

MainActivity.kt
override fun navigatorConfigurations() = listOf(
  NavigatorConfiguration(
    name = "main",
    startLocation = KanbanBoard.current.url,
    navigatorHostId = R.id.main_nav_host
  )
)

We use the KanbanBoard class to store remote and local URLs. Open java/com/example/hotwirenativeworkshop/KanbanBoard.kt and on line 5, change:

Environment.Remote β†’ Environment.Local

ios Open HotwireKanban/SceneDelegate.swift.

Here we configure the Navigator with the backend endpoint:

SceneDelegate.swift
import HotwireNative
import UIKit

class SceneDelegate: UIResponder, UIWindowSceneDelegate {
    var window: UIWindow?

    private let navigator = Navigator(configuration: .init(
        name: "main",
        startLocation: KanbanBoard.current.url
    ))

    func scene(_ scene: UIScene, willConnectTo session: UISceneSession,
               options connectionOptions: UIScene.ConnectionOptions) {
        window?.rootViewController = navigator.rootViewController
        navigator.start()
    }
}

We use the KanbanBoard enum to store remote and local URLs. Open HotwireKanban/KanbanBoard.swift and on line 4, change:

.remote β†’ .local

KanbanBoard.swift
enum KanbanBoard {
    static let current: Environment = .local  // Change from .remote

    enum Environment {
        case remote
        case local

        var url: URL {
            switch self {
            case .remote:
                return URL(string: "https://hotwire-native-demo.dev")!
            case .local:
                return URL(string: "http://localhost:3000")!
            }
        }
    }
}

Native Title rails

Open app/views/boards/index.html.erb and add at the top:

app/views/boards/index.html.erb
<% content_for :title, "Boards" if turbo_native_app? %>

Optionally, update the board's show page as well:

app/views/boards/show.html.erb
<% content_for :title, @board.name if turbo_native_app? %>
2First Bridge Component: ButtonComponent

Create ButtonComponent

android

  1. In the project root com.example.hotwirenativeworkshop, create a new package: bridge.
  2. Inside it, create a Kotlin Class/File named ButtonComponent.
  3. Paste the scaffold:
bridge/ButtonComponent.kt
package com.example.hotwirenativeworkshop.bridge

import android.util.Log
import dev.hotwire.core.bridge.BridgeComponent
import dev.hotwire.core.bridge.BridgeDelegate
import dev.hotwire.core.bridge.Message
import dev.hotwire.navigation.destinations.HotwireDestination
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

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

    override fun onReceive(message: Message) {
        when (message.event) {
            "connect" -> handleConnectEvent(message)
            else -> Log.w("ButtonComponent", "Unknown event for message: $message")
        }
    }

    private fun handleConnectEvent(message: Message) {
        val data = message.data<MessageData>() ?: return
        // Display a native submit button in the toolbar using data.title
    }

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

    @Serializable
    data class MessageData(
        @SerialName("title") val title: String
    )
}

ios

  1. In HotwireKanban/, create a new folder: Bridge.
  2. Inside it, create a Swift file named ButtonComponent.swift.
  3. Paste the scaffold:
Bridge/ButtonComponent.swift
import HotwireNative
import UIKit

final class ButtonComponent: BridgeComponent {
    override class var name: String { "button" }

    override func onReceive(message: Message) {
        guard let event = message.event else { return }

        switch event {
        case "connect":
            handleConnectEvent(message: message)
        default:
            print("ButtonComponent: Unknown event \(event)")
        }
    }

    private func handleConnectEvent(message: Message) {
        guard let data: MessageData = message.data() else { return }
        // Display a native button in the toolbar using data.title
    }

    private func performButtonClick() {
        reply(to: "connect")
    }
}

private extension ButtonComponent {
    struct MessageData: Decodable {
        let title: String
    }
}

Register the Component

android Open java/com/example/hotwirenativeworkshop/KanbanBoardApplication.kt and add inside configureApp:

KanbanBoardApplication.kt
// Register bridge components
Hotwire.registerBridgeComponents(
    BridgeComponentFactory("button", ::ButtonComponent)
)

ios Open HotwireKanban/AppDelegate.swift and add inside application(_:didFinishLaunchingWithOptions:):

AppDelegate.swift
// Register bridge components
Hotwire.registerBridgeComponents([
    ButtonComponent.self
])

Implement ButtonComponent

android Back in bridge/ButtonComponent.kt, add these private properties at the top of the class:

bridge/ButtonComponent.kt β€” properties
private val buttonItemId = 37
private var buttonMenuItem: MenuItem? = null

private val fragment: Fragment
    get() = delegate.destination.fragment

private val toolbar: Toolbar?
    get() = fragment.view?.findViewById(R.id.toolbar)

Add the showToolbarButton method:

bridge/ButtonComponent.kt β€” showToolbarButton
private fun showToolbarButton(data: MessageData) {
    val menu = toolbar?.menu ?: return
    val order = 999

    val title = SpannableString(data.title).apply {
        setSpan(ForegroundColorSpan("#FF6600".toColorInt()), 0, length, 0)
    }

    menu.removeItem(buttonItemId)
    buttonMenuItem = menu.add(Menu.NONE, buttonItemId, order, title).apply {
        setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS)
        setOnMenuItemClickListener {
            performButtonClick()
            true
        }
    }
}

Now call it from handleConnectEvent:

bridge/ButtonComponent.kt β€” handleConnectEvent
private fun handleConnectEvent(message: Message) {
    val data = message.data<MessageData>() ?: return
    showToolbarButton(data)
}

ios Back in Bridge/ButtonComponent.swift, add these private properties at the top of the class:

Bridge/ButtonComponent.swift β€” properties
private var barButtonItem: UIBarButtonItem?

private var viewController: UIViewController? {
    delegate?.destination as? UIViewController
}

Add the showToolbarButton method:

Bridge/ButtonComponent.swift β€” showToolbarButton
private func showToolbarButton(title: String) {
    guard let viewController = viewController else { return }

    let action = UIAction { [weak self] _ in
        self?.performButtonClick()
    }

    barButtonItem = UIBarButtonItem(title: title, primaryAction: action)
    barButtonItem?.tintColor = UIColor(red: 1.0, green: 0.4, blue: 0.0, alpha: 1.0) // #FF6600

    viewController.navigationItem.rightBarButtonItem = barButtonItem
}

Now call it from handleConnectEvent:

Bridge/ButtonComponent.swift β€” handleConnectEvent
private func handleConnectEvent(message: Message) {
    guard let data: MessageData = message.data() else { return }
    showToolbarButton(title: data.title)
}

Install Hotwire Native Bridge rails

The Rails app needs the @hotwired/hotwire-native-bridge JS library. Navigate to your Rails repo and run:

terminal
./bin/importmap pin @hotwired/hotwire-native-bridge

Button Stimulus Controller rails

Create app/javascript/controllers/bridge/button_controller.js:

app/javascript/controllers/bridge/button_controller.js
import { BridgeComponent } from "@hotwired/hotwire-native-bridge"

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

  connect() {
    super.connect()

    const element = this.bridgeElement
    const title = element.bridgeAttribute("title")
    this.send("connect", {title}, () => {
      this.element.click()
    })
  }
}

Open app/views/boards/index.html.erb and replace the β€œNew Board” link (line 8):

app/views/boards/index.html.erb
<%= link_to 'New Board', new_board_path,
            class: 'btn btn-outline-primary',
            data: {
              controller: "bridge--button",
              bridge_title: "New Board",
            } %>
πŸ”–Checkpoint branch: button-component β€” switch on Rails, Android, and iOS repositories.
3Introduce Modal

Enable Modal Navigation

Two changes are needed: use _top to break through the TurboFrame (triggering navigation), and tell the app to use a modal for /boards/new.

rails Open app/views/boards/index.html.erb and update the link:

app/views/boards/index.html.erb
<%= link_to 'New Board', new_board_path,
            class: 'btn btn-outline-primary',
            data: {
              controller: "bridge--button",
              bridge_title: "New Board",
              **(turbo_native_app? ? { turbo_frame: "_top" } : {})
            } %>

android Open assets/json/path-configuration.json and add a new rule:

assets/json/path-configuration.json
{
  "patterns": [
    "/boards/new$"
  ],
  "properties": {
    "context": "modal",
    "uri": "hotwire://fragment/web/modal/sheet",
    "pull_to_refresh_enabled": false
  }
}

ios Open HotwireKanban/path-configuration.json and add a new rule:

path-configuration.json
{
  "patterns": [
    "/boards/new$"
  ],
  "properties": {
    "context": "modal",
    "pull_to_refresh_enabled": false
  }
}

Note: iOS doesn't need the "uri" property β€” the Navigator handles modal presentation automatically based on "context": "modal".

Hide Redundant HTML rails

Since native UI replaces the β€œNew Board” button and the page title, hide the web equivalents. Add to app/assets/stylesheets/kanban.scss:

app/assets/stylesheets/kanban.scss
// Hotwire Native: hide elements replaced by native UI
.turbo-native {
  // Hide the "New Board" button β€” replaced by the native toolbar button
  [data-controller="bridge--button"] {
    display: none;
  }

  // Hide page headers β€” title and actions are shown in the native toolbar
  .boards-header {
    display: none;
  }
}

The turbo-native class controls CSS differently on web vs. native. Add it to the body in app/views/layouts/application.html.erb:

app/views/layouts/application.html.erb
<body class="<%= 'turbo-native' if turbo_native_app? %>">

Make Modal Dismiss on Submit rails

After submitting, the modal stays visible because the server responds with Turbo Streams inside the TurboFrame. To fix this, break through the frame with _top and force the server to respond with a redirect.

Replace line 1 in app/views/boards/_form.html.erb:

app/views/boards/_form.html.erb
<%= form_for @board, class: 'col-12', data: ({ turbo_frame: '_top' } if turbo_native_app?) do |form| %>

In app/controllers/boards_controller.rb, add a before action and use a native variant in the create response:

app/controllers/boards_controller.rb β€” before_action
before_action :set_native_variant, if: -> { turbo_native_app? }, only: %i[ create ]

private

def set_native_variant
  request.variant = :native
end
app/controllers/boards_controller.rb β€” create
def create
  @board = Board.new(board_params)

  respond_to do |format|
    if @board.save
      format.html { redirect_to boards_url, notice: "Board was successfully created." }
      format.turbo_stream.native { redirect_to boards_url }
      format.turbo_stream
    else
      format.html { render :new, status: :unprocessable_entity }
    end
  end
end

Fix Board Stacking

After the modal dismisses, the refreshed boards page is pushed on top of the previous one. Fix this by adding a replace presentation rule:

android Open assets/json/path-configuration.json:

assets/json/path-configuration.json
{
  "patterns": [
    "/boards$"
  ],
  "properties": {
    "context": "default",
    "uri": "hotwire://fragment/web",
    "presentation": "replace",
    "pull_to_refresh_enabled": true
  }
}

ios Open HotwireKanban/path-configuration.json:

path-configuration.json
{
  "patterns": [
    "/boards$"
  ],
  "properties": {
    "context": "default",
    "presentation": "replace",
    "pull_to_refresh_enabled": true
  }
}
πŸ”–Checkpoint branch: new-board-modal β€” switch on Rails, Android, and iOS repositories.
4Form Component

Create FormComponent

android Create a new Kotlin class bridge/FormComponent:

bridge/FormComponent.kt
package com.example.hotwirenativeworkshop.bridge

import android.util.Log
import dev.hotwire.core.bridge.BridgeComponent
import dev.hotwire.core.bridge.BridgeDelegate
import dev.hotwire.core.bridge.Message
import dev.hotwire.navigation.destinations.HotwireDestination
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

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

    override fun onReceive(message: Message) {
        when (message.event) {
            "connect" -> handleConnectEvent(message)
            "submitEnabled" -> handleSubmitEnabled()
            "submitDisabled" -> handleSubmitDisabled()
            else -> Log.w("FormComponent", "Unknown event for message: $message")
        }
    }

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

    private fun showToolbarButton(data: MessageData) {
        // Display a native submit button in the toolbar using data.title
    }

    private fun handleSubmitEnabled() {
        toggleSubmitButton(true)
    }

    private fun handleSubmitDisabled() {
        toggleSubmitButton(false)
    }

    private fun toggleSubmitButton(enable: Boolean) {
        // TODO
    }

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

    @Serializable
    data class MessageData(
        @SerialName("submitTitle") val title: String
    )
}

ios Create a new Swift file Bridge/FormComponent.swift:

Bridge/FormComponent.swift
import HotwireNative
import UIKit

final class FormComponent: BridgeComponent {
    override class var name: String { "form" }

    override func onReceive(message: Message) {
        guard let event = message.event else { return }

        switch event {
        case "connect":
            handleConnectEvent(message: message)
        case "submitEnabled":
            handleSubmitEnabled()
        case "submitDisabled":
            handleSubmitDisabled()
        default:
            print("FormComponent: Unknown event \(event)")
        }
    }

    private func handleConnectEvent(message: Message) {
        guard let data: MessageData = message.data() else { return }
        showToolbarButton(title: data.submitTitle)
    }

    private func showToolbarButton(title: String) {
        // Display a native submit button using title
    }

    private func handleSubmitEnabled() {
        // Enable the submit button
    }

    private func handleSubmitDisabled() {
        // Disable the submit button
    }

    private func performSubmit() {
        reply(to: "connect")
    }
}

private extension FormComponent {
    struct MessageData: Decodable {
        let submitTitle: String
    }
}

Register FormComponent

android In KanbanBoardApplication.kt, add FormComponent to the registration list:

KanbanBoardApplication.kt
Hotwire.registerBridgeComponents(
    BridgeComponentFactory("button", ::ButtonComponent),
    BridgeComponentFactory("form", ::FormComponent)
)

ios In AppDelegate.swift, add FormComponent to the registration array:

AppDelegate.swift
Hotwire.registerBridgeComponents([
    ButtonComponent.self,
    FormComponent.self
])

Implement FormComponent

android Add private properties at the top of the class:

bridge/FormComponent.kt β€” properties
private val submitButtonItemId = 38
private var submitMenuItem: MenuItem? = null

private val fragment: Fragment
    get() = delegate.destination.fragment

private val toolbar: Toolbar?
    get() = fragment.view?.findViewById(R.id.toolbar)

Replace the showToolbarButton method:

bridge/FormComponent.kt β€” showToolbarButton
private fun showToolbarButton(data: MessageData) {
    val menu = toolbar?.menu ?: return
    val order = 999

    val title = SpannableString(data.title).apply {
        setSpan(ForegroundColorSpan("#FF6600".toColorInt()), 0, length, 0)
    }

    menu.removeItem(submitButtonItemId)
    submitMenuItem = menu.add(Menu.NONE, submitButtonItemId, order, title).apply {
        setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS)
        setOnMenuItemClickListener {
            performSubmit()
            true
        }
    }
}

ios Add private properties at the top of the class:

Bridge/FormComponent.swift β€” properties
private var submitBarButtonItem: UIBarButtonItem?

private var viewController: UIViewController? {
    delegate?.destination as? UIViewController
}

Replace the showToolbarButton and toggle methods:

Bridge/FormComponent.swift β€” implementation
private func showToolbarButton(title: String) {
    guard let viewController = viewController else { return }

    let action = UIAction { [weak self] _ in
        self?.performSubmit()
    }

    submitBarButtonItem = UIBarButtonItem(title: title, primaryAction: action)
    submitBarButtonItem?.tintColor = UIColor(red: 1.0, green: 0.4, blue: 0.0, alpha: 1.0)

    viewController.navigationItem.rightBarButtonItem = submitBarButtonItem
}

private func handleSubmitEnabled() {
    submitBarButtonItem?.isEnabled = true
}

private func handleSubmitDisabled() {
    submitBarButtonItem?.isEnabled = false
}

Form Stimulus Controller rails

Create app/javascript/controllers/bridge/form_controller.js:

app/javascript/controllers/bridge/form_controller.js
import { BridgeComponent } from "@hotwired/hotwire-native-bridge"
import { BridgeElement } from "@hotwired/hotwire-native-bridge"

export default class extends BridgeComponent {
  static component = "form"
  static targets = [ "submit" ]

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

  notifyBridgeOfConnect() {
    const submitButton = new BridgeElement(this.submitTarget)
    const submitTitle = submitButton.title

    this.send("connect", { submitTitle }, () => {
      this.submitTarget.click()
    })
  }

  submitStart(event) {
    this.submitTarget.disabled = true
    this.send("submitDisabled")
  }

  submitEnd(event) {
    this.submitTarget.disabled = false
    this.send("submitEnabled")
  }
}

Update the form and submit button in app/views/boards/_form.html.erb:

app/views/boards/_form.html.erb β€” form
<%= form_for @board,
            class: 'col-12',
            data: {
              controller: "bridge--form",
              action: "turbo:submit-start->bridge--form#submitStart turbo:submit-end->bridge--form#submitEnd",
              **(turbo_native_app? ? { turbo_frame: "_top" } : {})
            } do |form| %>

Replace the submit button:

app/views/boards/_form.html.erb β€” submit button
<%= form.submit 'Save',
                class: 'btn btn-outline-primary',
                data: {
                  bridge__form_target: "submit",
                  bridge_title: "Save",
                } %>

Separate Native View rails

The form works but looks too web. Instead of adding conditional CSS, create a dedicated native view to keep the code readable.

Create app/views/boards/new.html+native.erb:

app/views/boards/new.html+native.erb
<% content_for :title, "New Board" %>

<%= form_for @board,
      data: {
        controller: "bridge--form",
        action: "turbo:submit-start->bridge--form#submitStart turbo:submit-end->bridge--form#submitEnd",
        turbo_frame: "_top"
      } do |form| %>

  <% if @board.errors.any? %>
    <% @board.errors.full_messages.each do |message| %>
      <p class="text-center text-orange-600"><%= message %></p>
    <% end %>
  <% end %>

  <div class="px-3 py-2">
    <%= form.text_field :name, placeholder: 'Board name', class: 'form-control w-100' %>
  </div>

  <%= form.submit 'Save', class: 'd-none',
        data: { bridge__form_target: "submit", bridge_title: "Save" } %>
<% end %>

Restore app/views/boards/_form.html.erb to the original web form:

app/views/boards/_form.html.erb
<%= form_for @board, class: 'col-12' do |form| %>
  <% if @board.errors.any? %>
    <% @board.errors.full_messages.each do |message| %>
      <p class="text-center text-orange-600">
      <%= message %>
      </p>
    <% end %>
  <% end %>

  <div class="row">
    <div class="col">
      <div class="form-group my-2">
        <%= form.text_field :name, placeholder: 'Board name', class: 'form-control' %>
      </div>
    </div>

    <div class="col d-flex align-items-end">
      <div class="actions mb-2 text-center">
        <%= link_to 'Cancel', boards_path, class: 'btn btn-outline-info' %>
        <%= form.submit 'Save', class: 'btn btn-outline-primary' %>
      </div>
    </div>
  </div>
<% end %>

In app/controllers/boards_controller.rb, add new to the before action:

app/controllers/boards_controller.rb
before_action :set_native_variant, if: -> { turbo_native_app? }, only: %i[ new create ]
πŸ”–Checkpoint branch: form-component β€” switch on Rails, Android, and iOS repositories.
Resources

Further Reading

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