From Rails to App Store in Days: Native Mobile Apps with Hotwire Native
Ruby on Rails was created with the idea that one developer can do it all. Now this philosophy extends to mobile development! With Hotwire Native, you can turn your Rails application into a native iOS app with TabBar, push notifications, and native elements in just a few days.
At JetRockets, we recommend this approach: first create a web application on Rails, then add a native iOS application for the App Store in days. For most services, this is the perfect solution – why complicate the project ahead of time?
Why Does This Make Sense?
📱 Most Apps Don't Need "True Native"
Let's be honest: 90% of mobile apps are just beautiful wrappers around web content. News feeds, profiles, lists, forms – all of this works perfectly through WebView with the right architecture.
⚡️ One Codebase for Everything
Write logic once in Rails
Design and layout work in both browser and app
Updates happen instantly without App Store releases
Team doesn't bloat – you need one Rails developer
🎯 When to Add Native Elements
Add native elements only where it really matters:
TabBar for convenient navigation
Push notifications for engagement
Special screens (camera, geolocation) when needed
Solution Architecture
Hotwire Native is an iOS framework from the creators of Rails that:
Wraps your web application in WKWebView
Adds native navigation and TabBar
Provides JavaScript Bridge for communication between web and native
Ensures smooth transitions and native behavior
┌─────────────────────────────────────┐
│ iOS App (Swift/UIKit) │
│ ┌─────────────────────────────┐ │
│ │ Native TabBar │ │
│ │ 🏠 📊 ➕ 🏆 ⚙️ │ │
│ └─────────────────────────────┘ │
│ ┌─────────────────────────────┐ │
│ │ Hotwire Native Navigator │ │
│ │ (handles navigation) │ │
│ └─────────────────────────────┘ │
│ ┌─────────────────────────────┐ │
│ │ WKWebView │ │
│ │ (Rails app content) │ │
│ └─────────────────────────────┘ │
└─────────────────────────────────────┘
Step-by-Step Guide
Prerequisites
Assume you already have a working Rails application with Hotwire (Turbo + Stimulus) and the following URLs:
Example routes in your application:
/– home page (dashboard)/matches– list of matches/matches/new– form to create a new match/matches/:id– match detail page/players– player rankings/players/:id– player profile/profile– current user settings
Step 1: Creating iOS Project
Create a new project in Xcode (File → New → Project → iOS App).
Add Hotwire Native dependency via Swift Package Manager:
https://github.com/hotwired/hotwire-native-ios
Step 2: Application Configuration
Create configuration to switch between dev and production:
// AppConfiguration.swift
import Foundation
enum AppConfiguration {
static var baseURL: URL {
#if DEBUG
// For local development
return URL(string: "http://localhost:3000")!
#else
// For production
return URL(string: "https://domain.com")!
#endif
}
}
Important for local development:
In Rails, start server:
rails s -b 0.0.0.0In iOS, use your computer's IP instead of
localhostwhen testing on a real deviceOr use the simulator where
localhostworks correctly
Step 3: Creating Native TabBar
3.1 Create Tab Configurator
// TabBarConfigurator.swift
import UIKit
final class TabBarConfigurator {
struct TabItemConfiguration {
let title: String
let imageName: String
let pointSize: CGFloat
let useCustomColor: Bool
init(
title: String,
imageName: String,
pointSize: CGFloat = 0,
useCustomColor: Bool = false
) {
self.title = title
self.imageName = imageName
self.pointSize = pointSize
self.useCustomColor = useCustomColor
}
}
static func configureTabBarItem(
for viewController: UIViewController,
with configuration: TabItemConfiguration
) {
let image: UIImage?
if configuration.useCustomColor {
let config = UIImage.SymbolConfiguration(
pointSize: configuration.pointSize,
weight: .bold,
scale: .large
)
image = UIImage(
systemName: configuration.imageName,
withConfiguration: config
)?.withTintColor(.systemBlue, renderingMode: .alwaysOriginal)
} else {
image = UIImage(systemName: configuration.imageName)
}
viewController.tabBarItem = UITabBarItem(
title: configuration.title,
image: image,
selectedImage: image
)
}
static func configureAccentColor(for tabBar: UITabBar) {
tabBar.tintColor = UIColor(named: "AccentColor")
}
}
// MARK: - Preset Tab Configurations
extension TabBarConfigurator.TabItemConfiguration {
static let home = TabBarConfigurator.TabItemConfiguration(
title: "Home",
imageName: "house.fill"
)
static let matches = TabBarConfigurator.TabItemConfiguration(
title: "Matches",
imageName: "sportscourt.fill"
)
static let newMatch = TabBarConfigurator.TabItemConfiguration(
title: "",
imageName: "plus.circle.fill",
pointSize: 30,
useCustomColor: true
)
static let players = TabBarConfigurator.TabItemConfiguration(
title: "Rankings",
imageName: "trophy.fill"
)
static let settings = TabBarConfigurator.TabItemConfiguration(
title: "Profile",
imageName: "person.fill"
)
}
3.2 Create Navigator Factory
// NavigatorFactory.swift
import HotwireNative
import UIKit
final class NavigatorFactory {
enum NavigatorType {
case main
case matches
case newMatch
case players
case settings
var name: String {
switch self {
case .main: return "main"
case .matches: return "matches"
case .newMatch: return "newMatch"
case .players: return "players"
case .settings: return "settings"
}
}
var path: String {
switch self {
case .main: return ""
case .matches: return "matches"
case .newMatch: return "matches/new"
case .players: return "players"
case .settings: return "profile"
}
}
}
static func createNavigator(
type: NavigatorType,
delegate: NavigatorDelegate
) -> Navigator {
let url = AppConfiguration.baseURL.appendingPathComponent(type.path)
let configuration = Navigator.Configuration(
name: type.name,
startLocation: url
)
let navigator = Navigator(configuration: configuration)
navigator.delegate = delegate
configureNavigationBar(for: navigator)
return navigator
}
private static func configureNavigationBar(for navigator: Navigator) {
let appearance = UINavigationBarAppearance()
appearance.configureWithTransparentBackground()
let navigationBar = navigator.rootViewController.navigationBar
navigationBar.standardAppearance = appearance
navigationBar.scrollEdgeAppearance = appearance
navigationBar.compactAppearance = appearance
}
}
3.3 Create Router for Handling Transitions
// AppRouter.swift
import HotwireNative
import UIKit
final class AppRouter {
private weak var tabBarController: UITabBarController?
private let mainNavigator: Navigator
private let matchesNavigator: Navigator
private let newMatchNavigator: Navigator
private let playersNavigator: Navigator
private let settingsNavigator: Navigator
init(
tabBarController: UITabBarController,
mainNavigator: Navigator,
matchesNavigator: Navigator,
newMatchNavigator: Navigator,
playersNavigator: Navigator,
settingsNavigator: Navigator
) {
self.tabBarController = tabBarController
self.mainNavigator = mainNavigator
self.matchesNavigator = matchesNavigator
self.newMatchNavigator = newMatchNavigator
self.playersNavigator = playersNavigator
self.settingsNavigator = settingsNavigator
}
func startAllNavigators() {
mainNavigator.start()
matchesNavigator.start()
newMatchNavigator.start()
playersNavigator.start()
settingsNavigator.start()
}
func handleProposal(_ proposal: VisitProposal) -> ProposalResult {
let path = proposal.url.path
// Route by path
switch path {
case _ where path == "/matches/new":
switchToTab(.newMatch)
newMatchNavigator.route(proposal.url)
case _ where path.hasPrefix("/matches"):
switchToTab(.matches)
matchesNavigator.route(proposal.url)
case _ where path.hasPrefix("/players"):
switchToTab(.players)
playersNavigator.route(proposal.url)
case _ where path.hasPrefix("/profile"):
switchToTab(.settings)
settingsNavigator.route(proposal.url)
default:
switchToTab(.main)
mainNavigator.route(proposal.url)
}
return .reject
}
func resetNavigatorToRoot(at index: Int) {
let navigator = navigatorForTabIndex(index)
navigator?.rootViewController.popToRootViewController(animated: false)
}
// MARK: - Private
private enum TabIndex: Int {
case main = 0
case matches = 1
case newMatch = 2
case players = 3
case settings = 4
}
private func switchToTab(_ tab: TabIndex) {
tabBarController?.selectedIndex = tab.rawValue
}
private func navigatorForTabIndex(_ index: Int) -> Navigator? {
switch TabIndex(rawValue: index) {
case .main: return mainNavigator
case .matches: return matchesNavigator
case .newMatch: return newMatchNavigator
case .players: return playersNavigator
case .settings: return settingsNavigator
case .none: return nil
}
}
}
3.4 Create Main App Coordinator
// AppCoordinator.swift
import HotwireNative
import UIKit
final class AppCoordinator: NSObject {
private let window: UIWindow
private var router: AppRouter?
// Navigators for each tab
private lazy var mainNavigator = createNavigator(type: .main)
private lazy var matchesNavigator = createNavigator(type: .matches)
private lazy var newMatchNavigator = createNavigator(type: .newMatch)
private lazy var playersNavigator = createNavigator(type: .players)
private lazy var settingsNavigator = createNavigator(type: .settings)
// TabBar Controller
private lazy var tabBarController: UITabBarController = {
let tabBar = UITabBarController()
tabBar.delegate = self
// Configure tab items
TabBarConfigurator.configureTabBarItem(
for: mainNavigator.rootViewController,
with: .home
)
TabBarConfigurator.configureTabBarItem(
for: matchesNavigator.rootViewController,
with: .matches
)
TabBarConfigurator.configureTabBarItem(
for: newMatchNavigator.rootViewController,
with: .newMatch
)
TabBarConfigurator.configureTabBarItem(
for: playersNavigator.rootViewController,
with: .players
)
TabBarConfigurator.configureTabBarItem(
for: settingsNavigator.rootViewController,
with: .settings
)
// Set view controllers
tabBar.viewControllers = [
mainNavigator.rootViewController,
matchesNavigator.rootViewController,
newMatchNavigator.rootViewController,
playersNavigator.rootViewController,
settingsNavigator.rootViewController
]
TabBarConfigurator.configureAccentColor(for: tabBar.tabBar)
return tabBar
}()
init(window: UIWindow) {
self.window = window
}
func start() {
let router = AppRouter(
tabBarController: tabBarController,
mainNavigator: mainNavigator,
matchesNavigator: matchesNavigator,
newMatchNavigator: newMatchNavigator,
playersNavigator: playersNavigator,
settingsNavigator: settingsNavigator
)
self.router = router
window.overrideUserInterfaceStyle = .light
window.rootViewController = tabBarController
window.makeKeyAndVisible()
router.startAllNavigators()
}
private func createNavigator(type: NavigatorFactory.NavigatorType) -> Navigator {
return NavigatorFactory.createNavigator(type: type, delegate: self)
}
}
// MARK: - NavigatorDelegate
extension AppCoordinator: NavigatorDelegate {
func handle(proposal: VisitProposal) -> ProposalResult {
guard let router = router else { return .reject }
return router.handleProposal(proposal)
}
}
// MARK: - UITabBarControllerDelegate
extension AppCoordinator: UITabBarControllerDelegate {
func tabBarController(
_ tabBarController: UITabBarController,
didSelect viewController: UIViewController
) {
guard let selectedIndex = tabBarController.viewControllers?.firstIndex(of: viewController) else {
return
}
router?.resetNavigatorToRoot(at: selectedIndex)
}
}
3.5 Connect Coordinator in SceneDelegate
// SceneDelegate.swift
import UIKit
final class SceneDelegate: UIResponder, UIWindowSceneDelegate {
var window: UIWindow?
private var coordinator: AppCoordinator?
func scene(
_ scene: UIScene,
willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions
) {
guard let windowScene = scene as? UIWindowScene else { return }
let window = UIWindow(windowScene: windowScene)
self.window = window
let coordinator = AppCoordinator(window: window)
self.coordinator = coordinator
coordinator.start()
}
}
Step 4: Local Development Setup
To work with localhost on a real device:
Start Rails server on all interfaces:
rails s -b 0.0.0.0
Get your Mac's IP address:
ipconfig getifaddr en0 # for WiFi
# or
ipconfig getifaddr en1 # for Ethernet
Update AppConfiguration for testing:
enum AppConfiguration {
static var baseURL: URL {
#if DEBUG
// Replace with your Mac's IP
return URL(string: "http://192.168.1.100:3000")!
#else
return URL(string: "https://yourapp.com")!
#endif
}
}
Add HTTP exception in Info.plist:
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
Step 5: Adding JavaScript Bridge (Optional)
For communication between Rails and iOS, create bridge components:
// CustomBridgeComponent.swift
import HotwireNative
import UIKit
final class CustomBridgeComponent: BridgeComponent {
override class var name: String { "custom" }
override func onReceive(message: Message) {
guard let event = Event(rawValue: message.event) else { return }
switch event {
case .showAlert:
handleShowAlert(message: message)
case .vibrate:
handleVibrate()
}
}
private func handleShowAlert(message: Message) {
guard let data = message.data as? [String: Any],
let title = data["title"] as? String,
let text = data["text"] as? String else { return }
let alert = UIAlertController(
title: title,
message: text,
preferredStyle: .alert
)
alert.addAction(UIAlertAction(title: "OK", style: .default))
delegate?.destination.topViewController?.present(alert, animated: true)
}
private func handleVibrate() {
UIImpactFeedbackGenerator(style: .medium).impactOccurred()
}
private enum Event: String {
case showAlert = "show-alert"
case vibrate
}
}
Register the component in AppDelegate:
// AppDelegate.swift
import HotwireNative
import UIKit
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
// Register bridge components
Hotwire.registerBridgeComponents([
CustomBridgeComponent.self
])
return true
}
}
Now you can call native functions from Rails:
<%# app/views/matches/show.html.erb %>
<button data-controller="bridge"
data-action="click->bridge#send"
data-bridge-component="custom"
data-bridge-event="vibrate">
Vibrate!
</button>
<button data-controller="bridge"
data-action="click->bridge#send"
data-bridge-component="custom"
data-bridge-event="show-alert"
data-bridge-data='{"title": "Success!", "text": "Match saved"}'>
Show Alert
</button>
Result
✅ Native TabBar with 5 tabs
✅ Independent navigation in each tab
✅ Smooth transitions between screens
✅ localhost support for development
✅ Production-ready architecture
✅ JavaScript Bridge for native functions
Next Steps
1. Push Notifications
Add ability to send push notifications via APNs:
# Gemfile
gem 'rpush'
# config/initializers/rpush.rb
Rpush.configure do |config|
config.client = :active_record
end
2. Custom Native Screens
For special features (camera, maps), create separate native view controllers:
final class CameraViewController: UIViewController {
// Native camera work
}
3. Performance Optimization
Cache web content
Use Turbo Frames for partial updates
Add offline mode
4. Release Preparation
Configure icons and splash screen
Add App Store Connect metadata
Set up CI/CD for automatic builds
Why JetRockets Chooses This Approach?
💰 Client Budget Savings
Instead of hiring iOS, Android, and backend developers – one Rails full-stack developer handles everything.
⚡️ Speed to Market
From idea to App Store application – weeks, not months.
🔄 Fast Iterations
Web updates apply instantly without App Store review.
🎯 Focus on Business Logic
Instead of fighting with native UI frameworks – focus on solving client problems.
📱 Ready to Scale
If needed – easily add native modules in critical places.
When NOT to Use This Approach?
⚠️ Games – high performance needed
⚠️ Heavy graphics applications – 3D, video editors
⚠️ Offline-first apps – full autonomy needed
⚠️ Complex hardware work – Bluetooth, NFC, sensors
For everything else – Hotwire Native is the perfect choice! 🚀
Conclusion
Rails changed web development, making it possible for one developer to create full-featured web applications. Hotwire Native continues this philosophy in the mobile world.
You no longer need to choose between development speed and native experience. You can have both.
At JetRockets, we're ready to help you:
Develop a Rails web application
Package it into a native iOS app
Configure push notifications and special features
Publish to the App Store
All of this – in weeks, not months. All of this – with one development team.
Useful Links
Ready to start? Contact the JetRockets team, and we'll turn your Rails application into a native mobile app faster than you think! 🚀