RUBY ON RAILS · 17 MIN READ ·

Hotwire Native for Rails: Build iOS and Android Apps Without React Native or Flutter

Hotwire Native for Rails: ship native iOS and Android apps from your Rails views with bridge components, path configuration, and no React Native required.

Hotwire Native for Rails: Build iOS and Android Apps Without React Native or Flutter

A SaaS founder hired me last summer because his investors had been asking the same question every quarter for two years: “When are you launching mobile?” His team was three Rails developers and one designer. He had quoted a React Native rebuild at four months and roughly two hundred thousand euros. We launched both an iOS and an Android app in nine working days using Hotwire Native, reusing 95% of the existing Rails views. The App Store review came back in 38 hours. His seed extension closed the following month.

I have been doing Rails for nineteen years, and Hotwire Native for Rails is the most underrated lever a small team has right now. It is not a “responsive web app pretending to be native.” It is real native shells (Swift on iOS, Kotlin on Android) that wrap your existing Rails views in WKWebView and WebView, with native navigation, real native components where you need them, and the ability to ship a UI change by pushing to main. This post is the playbook: when it is the right call, when it absolutely is not, and the bridge component pattern that turns a “wrapped web app” into something that feels indistinguishable from a native build.

What Hotwire Native Actually Is

Hotwire Native is the evolution of Turbo Native, rebranded and rebuilt by 37signals in 2024 around the Hotwire 8.0 release. The idea is simple and old: a tiny native shell handles navigation, gestures, and platform chrome; the actual screens are rendered as HTML by your Rails server. When a user taps a link, the shell asks Rails for the next page, Rails returns HTML, the shell presents it in a WKWebView (iOS) or WebView (Android) inside a native navigation controller.

What Hotwire Native added beyond the original Turbo Native is the path configuration: a JSON file your Rails app serves that tells the native shells how to present each URL. Should this path be a modal? A bottom-sheet? A tab? Should it use a native screen instead of the web view? You answer once, in Rails, and both apps obey.

The other piece is bridge components: a pattern for swapping out specific UI fragments with real native code while the rest of the page stays HTML. Camera capture, biometric authentication, push notification prompts, native charts — anything that genuinely needs to be native, you write once on each platform and call from any Rails view with a Stimulus controller and a data- attribute. Everything else stays HTML.

If you have read my piece on Rails Stimulus controllers in production, you already know how cleanly Stimulus integrates with this. Bridge components are just Stimulus controllers that talk to native code instead of the DOM.

When Hotwire Native Is the Right Call

This is the part most blog posts skip, so let me be specific. Hotwire Native for Rails is the right call when:

Your app is fundamentally CRUD wrapped in workflow. Dashboards, lists, forms, settings, message threads, document views, admin tools. Most B2B SaaS. Most internal apps. Most marketplaces.

Your team is small and Rails-fluent. Two to ten engineers who already ship Rails. You do not have the headcount to maintain a real React Native or Flutter codebase, which is a full second codebase no matter what the marketing says.

You ship features faster than the app stores can review. If you are pushing changes to production weekly, you do not want every UI tweak to require a TestFlight submission and a Play Console rollout.

Your users do not live or die by 60fps animations. Hotwire Native scrolling is excellent. Hotwire Native gesture-driven animation is not what you want. If you are building TikTok, build it native. If you are building a B2B project tracker, you will be fine.

It is the wrong call when you are building a game, a creative tool with continuous gestures, a video editor, or anything that needs to function offline for hours. For those, build native. For the other 80% of mobile apps people actually ship, Hotwire Native will save you a junior salary per year in maintenance.

Setting Up Hotwire Native in Rails

The Rails side is almost trivial. Add the gem, generate the path configuration, and serve it.

# Gemfile
gem "hotwire-native-rails", "~> 1.2"
bundle install
bin/rails generate hotwire_native:install

That generator drops a path configuration file at config/hotwire/native_path_configuration.json and wires up a controller action at /configurations/ios_v1.json and /configurations/android_v1.json. You should commit this file and version it carefully — every running app instance fetches it on launch.

{
  "settings": {
    "screenshots_enabled": true
  },
  "rules": [
    {
      "patterns": ["/new$", "/edit$"],
      "properties": {
        "context": "modal",
        "presentation": "default"
      }
    },
    {
      "patterns": ["/sign_in", "/sign_up"],
      "properties": {
        "context": "modal",
        "presentation": "fullscreen",
        "pull_to_refresh_enabled": false
      }
    },
    {
      "patterns": ["/projects/\\d+/messages/\\d+"],
      "properties": {
        "context": "default",
        "pull_to_refresh_enabled": false
      }
    },
    {
      "patterns": [".*"],
      "properties": {
        "context": "default",
        "presentation": "default"
      }
    }
  ]
}

The pattern matching runs against the path. New and edit forms become modals — that is the Rails-y mental model and it maps perfectly to the iOS modal sheet and Android bottom sheet. Sign-in is a fullscreen modal because you do not want users dismissing it accidentally. The rest of your app uses the default push navigation.

On the Rails view side, you can tell whether the request is coming from a Hotwire Native client and adapt the rendering. The gem adds a helper:

# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  before_action :set_native_view_paths

  private

  def set_native_view_paths
    return unless hotwire_native_app?
    prepend_view_path Rails.root.join("app/views/native")
  end
end
<%# app/views/native/projects/show.html.erb %>
<%# Mobile-optimized: bigger touch targets, no sidebar nav, simpler header %>
<%= render "shared/native_header", project: @project %>

<div class="px-4 py-3 space-y-2">
  <%= render @project.recent_tasks %>
</div>

<%= link_to new_project_task_path(@project),
            class: "fixed bottom-6 right-6 rounded-full bg-indigo-600 p-4 shadow-lg",
            data: { turbo_frame: "_top" } do %>
  <%= heroicon "plus", class: "size-6 text-white" %>
<% end %>

That is the whole pattern. Same controller, same routes, same models. The web users see the desktop layout; the native shells see the mobile layout. You can also ship one shared layout if you have already built responsive views — many teams do exactly that.

The Bridge Component Pattern

The honest weakness of any web-in-shell approach is that some things genuinely need to be native: camera, biometric auth, push notifications, deep haptics, native maps. Hotwire Native solves this with bridge components, which are the single concept that turns this approach from “good enough” into “indistinguishable for the user.”

Here is the contract. In your Rails view, you write a Stimulus controller and add a data-controller attribute. On the native side, you register a bridge component with the same name. When the page loads in a Hotwire Native shell, the bridge takes over rendering or behavior for that element. When the same page loads in a browser, the Stimulus controller provides a web fallback.

<%# app/views/users/_avatar_upload.html.erb %>
<div data-controller="bridge--camera"
     data-bridge--camera-upload-url-value="<%= user_avatar_path(@user) %>"
     data-bridge--camera-mime-types-value='["image/jpeg","image/png"]'>
  <button data-action="bridge--camera#capture"
          type="button"
          class="rounded-md bg-indigo-600 px-4 py-2 text-white">
    Take photo
  </button>
</div>
// app/javascript/controllers/bridge/camera_controller.js
import { BridgeComponent, BridgeElement } from "@hotwired/hotwire-native-bridge"

export default class extends BridgeComponent {
  static component = "camera"
  static values = { uploadUrl: String, mimeTypes: Array }

  capture(event) {
    event.preventDefault()
    this.send("capture", { uploadUrl: this.uploadUrlValue, mimeTypes: this.mimeTypesValue }, (response) => {
      if (response.data.success) {
        Turbo.visit(this.uploadUrlValue, { action: "replace" })
      }
    })
  }
}

On iOS, you register a Swift component that listens for capture, opens UIImagePickerController, uploads the resulting image to the uploadUrl, and replies with success: true. On Android, you do the same with ActivityResultContracts.PickVisualMedia. Both shells get one file each.

// iOS/HotwireNativeApp/Bridge/CameraComponent.swift
import HotwireNative
import UIKit

final class CameraComponent: BridgeComponent {
  override class var name: String { "camera" }

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

  private func presentCameraPicker(message: Message) {
    let picker = UIImagePickerController()
    picker.sourceType = .camera
    picker.delegate = self
    delegate.activeViewController.present(picker, animated: true)
  }
}

The user taps a button. On the web they get the standard file picker. On iOS they get the system camera UI. On Android they get the Material photo picker. Same Rails view, three correct experiences. A senior mobile developer can implement these bridge components on each platform in an afternoon, and you reuse them across every screen in the app.

The Real-World Workflow

Most teams I work with end up shipping in this rhythm:

A new feature starts as a Rails view that works on the desktop web. You build it normally — controllers, models, ERB, Stimulus, Turbo Frames. Once it works in the browser, you load the same URL in the iOS simulator and the Android emulator. Both shells already exist; both already know how to render that path because of the path configuration. The view appears, mobile-styled because of Tailwind responsive classes, with native navigation around it. Usually you adjust touch targets, hide a sidebar, and ship.

When you hit something that needs to be native — image capture, biometric unlock, push permission, a native picker — you add a bridge component once. The second time you need the same capability anywhere in the app, you just add the data attribute. No second feature ticket per platform.

App Store updates happen quarterly or less. The native shells are tiny — usually under five hundred lines of Swift or Kotlin per platform. They rarely change. The week-to-week feature work all ships through your normal Rails deploy pipeline, which is a properly tuned Kamal 2 setup for most of my clients.

Push notifications still go through APNs and FCM, but you trigger them from Rails with the rpush gem or directly via the Apple and Google APIs. Authentication uses your existing session cookie — the WKWebView and Android WebView both share cookies with the shell, which means once a user signs in via the bridge they stay signed in for normal session lifetime.

Deep linking is a path configuration entry. Universal Links on iOS and App Links on Android both route to the shell, which then presents the matching Rails view.

Performance and Where It Actually Breaks

Let me be honest about the failure modes I have seen with Hotwire Native in production.

Initial cold start of the web view on a low-end Android device can take 800ms to 1.5 seconds before the first content paint. That is mostly the WebView warmup, not your server. The fix is to preload a splash screen URL during app launch and keep one WebView warm. The gem ships helpers for this — use them.

Turbo.cache interactions with native modal dismissal can cause stale content. If a user opens a new modal, fills it out, submits, and the modal dismisses, the underlying screen needs to refresh. The path configuration pull_to_refresh_enabled: true covers most of this. For everything else, broadcast a Turbo Stream and let the screen update itself.

Forms with file uploads inside a modal can lose state if the user backgrounds the app during the picker. Add a hidden data-turbo-permanent wrapper on the form, or accept it and use a bridge component for the upload (which is what I recommend anyway).

Animations driven by CSS work; animations driven by JavaScript scroll listeners do not. The web view scrolling on iOS is owned by WKScrollView, which does not fire scroll events the way a browser does. Stick to CSS animations and intersection observers, and you will be fine.

Battery and memory profile is dramatically better than React Native. A typical Hotwire Native app idles around 35-50 MB of memory. A typical React Native app of the same complexity sits around 120-180 MB. This matters more than the marketing says, especially on Android.

Costs and What I Tell Clients

For a typical Rails SaaS shipping their first mobile app, my budget conversation goes like this. The native shell for iOS is two to three days of work. The native shell for Android is two to three days. Three to five bridge components (camera, push, biometrics, share sheet, native date picker) is another week. App Store and Play Console setup, certificates, screenshots, copy: a week. That is roughly four to five weeks of one senior developer’s time to ship both apps to both stores.

Compare to a React Native rebuild for an existing Rails app: three to six months minimum, plus permanent ongoing maintenance of a separate codebase, plus the cost of every UI change needing to land in two places. The math is not close for most teams.

The cost question I get most often is “what happens if 37signals stops maintaining this?” The shells are tiny and fork-able; the bridge protocol is a documented JSON message format; the Rails gem is a few hundred lines. If the ecosystem disappeared tomorrow, you would have a maintainable native shell that you fully own. The blast radius of betting on it is much smaller than betting on a React Native major version migration.

FAQ

Is Hotwire Native the same as Turbo Native?

Hotwire Native is the rebranded and modernized successor to Turbo Native. It includes the Turbo Native libraries for iOS and Android, plus the new path configuration system, bridge component framework, and the hotwire-native-rails gem. If you are starting today, use Hotwire Native — Turbo Native is in maintenance mode.

Can I use Hotwire Native if my app is not built with Hotwire on the web?

Yes, with caveats. The shells render any URL your Rails app serves, so a plain ERB or React-on-the-server app works fine. You will get less benefit from Turbo Drive’s instant navigation, and bridge components are easier to integrate when your existing web JavaScript already uses Stimulus. Most teams adopt Hotwire on the web first, then add native — the migration order matters less than people think.

How does authentication work in Hotwire Native apps?

The web view shares cookies with the native shell, so your existing Rails session cookie flow works without changes. Users sign in once, and the cookie persists across app launches. For biometric unlock you wrap the cookie in a Keychain (iOS) or EncryptedSharedPreferences (Android) entry and re-authenticate the user with Face ID or fingerprint at app launch. The Rails session does not need to know any of this.

Will my app get rejected by the App Store for being a “web view wrapper”?

Apple’s guideline 4.2 has rejected pure web view wrappers since 2017, but Hotwire Native apps consistently pass review because they include native navigation, native modal presentation, native bridge components for camera and other system features, and meaningfully more functionality than just loading a website. Of the dozens of apps I have helped ship this way, none have been rejected on 4.2. Add at least one bridge component before submitting and you will be fine.


Thinking about a mobile app for your Rails SaaS but dreading the React Native quote? TTB Software helps Rails teams ship Hotwire Native apps to the App Store and Google Play in weeks, not quarters. We have been doing Rails for nineteen years and we know which fights are worth picking.

#hotwire-native #hotwire-native-rails #rails-mobile-apps #turbo-native #ios-android-rails #hotwire-bridge-components #ruby-on-rails

Related Articles

Last section. Then please call.

It's a phone call. That's the worst it can get.

No discovery deck. No 45-minute "qualification" call. 30 minutes, your problem, my opinion. If we're a fit, you'll know by minute 12.

Direct line — answered by Roger
+31 6 5123 6132
Mon–Fri, 09:00–18:00 CET · Currently available

OR
info@ttb.software