Non-tech founder’s guide to choosing the right software development partner Download Ebook
Home>Blog>From rails to app store in days: native mobile apps with hotwire native

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:

  1. Wraps your web application in WKWebView

  2. Adds native navigation and TabBar

  3. Provides JavaScript Bridge for communication between web and native

  4. 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.0

  • In iOS, use your computer's IP instead of localhost when testing on a real device

  • Or use the simulator where localhost works 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:

  1. Develop a Rails web application

  2. Package it into a native iOS app

  3. Configure push notifications and special features

  4. 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! 🚀

Discover More Reads

Categories:

Recent Projects

We take pride in creating applications that drive growth and evolution, from niche startups to international companies.

Explore Portfolio

Let's Build Something Great Together

Let's discuss your project and explore how a Rails upgrade can become your competitive advantage. Contact us today to start the conversation.

*By submitting this form, you agree with JetRockets’ Privacy Policy