About the author:
Originally from the San Francisco Bay Area, Alexander Dubovoy is a Berlin-based coder and musician. He graduated from Yale University in May 2016, where he wrote an award-winning thesis on the history of jazz in the Soviet Union. Since graduating, he has worked as a freelance web developer. He teaches at Le Wagon, whose web development bootcamp he did in 2018, and loves helping students with tricky technical problems. He also manages an active performance schedule as an improvising musician. He loves to combine his passions for technology and music, particularly through his work at Groupmuse, a cooperative concert-presenting organization.
Introduction
Web developers, like me, often want to turn our websites into native mobile applications. Unfortunately, for a long time, mobile app development was essentially an entirely separate field, requiring different skillsets and knowledge of different languages like Swift or Kotlin. It also required building an API on the back-end so that the mobile app could send HTTP requests to it, which is potentially a significant infrastructure addition. As a result, some web developers gave up on the idea of native apps altogether, preferring to optimize their web sites as PWAs (“Progressive Web Apps”). PWAs certainly work well, in the sense that they will have full feature parity with your website (because they are your website), and they can be installed directly to users’ home screens. They’re also extremely easy to update (you just update your website!), unlike mobile apps, which require review rounds and can even lead to disputes over Apple’s or Google’s app policies. PWAs aren’t really native apps, however, and users won’t find them in app stores like the Apple App Store or Google Play Store.
Consequently, there’s been a series of technologies that have cropped up to make it easier for us web developers to dip our toes into mobile app development. Probably the best-known is React Native. React Native lets developers use JavaScript and React’s JSX syntax, which many developers already know, to build mobile apps that then get compiled and optimized for both iOS and Android. The fact that web developers can use a language we already use for the web and can write one codebase to export to both of the major mobile operating systems is a huge win. Part of React Native’s promise, however, was that we’d actually be able to share code between our website and our native app, but that hasn’t generally been my experience in working with it. It uses proprietary components that differ significantly enough from the web’s HTML that, even though the syntax would make sense to any web developer, I’ve ended up needing to learn a new framework after all. React Native, furthermore, can only load data from a backend via HTTP requests, so it ends up generally requiring that we build a backend API. And, unfortunately, it ended up not being the right choice for some larger companies, partially due to performance concerns and partially because they actually did want to write and integrate fully native code. Most notably, Airbnb switched away from it back to native code around 2021.
Though React Native is the right choice for some people, I would argue there’s still a hunger on the market for the “holy grail” of mobile apps for web developers. Essentially, that would be an app that works exactly like the website does and is as easy to code and update as a website is. Then, maybe sometimes it would be nice to enhance it with more native-feeling features. This is exactly where the new Hotwire Native framework comes in, which recently celebrated its 1.0 release. It turns websites “automatically” into mobile applications, complete with all their features, but it still allows for progressive enhancement with native code. It’s also developed by the same people as Hotwire, which comes automatically installed in Ruby on Rails, the web development framework we teach here at Le Wagon, so it integrates exceedingly well with Rails apps. Unlike React Native, however, it does not use a web language to compile to native code, so you do end up needing to write a bit of Swift or Kotlin.
Given that this technology is brand-new (!) and that it does require writing some native code, I thought it would be helpful for me to provide a getting-started guide to learn how to turn your Rails app into a native iOS app in only a few steps. I’m going to focus on iOS here for now, but there’s also an official guide for Android, and the underlying concepts are identical.
Prerequisites
You’ll need a Rails app (with turbo-rails installed, as is the default from Rails 7) that you’ve already built and deployed that you want to turn into a mobile app. I’ll be using the Le Wagon website for my demo.
You’ll also need to have Xcode installed, which, yes, means you’ll need to be using a Mac to follow this tutorial.
Starting our Xcode project
First, we’ll open up the Xcode application on our computer and start a new project.
We’ll choose “App”.
I’ll name my new project lewagon-hotwire-ios.
Please make sure that for “Language”, Swift is selected and not Objective-C.
You’ll also need to decide where to store it on your system, which can be anywhere you like. I’m going with ~/code/lewagon/.
Configuring our app icon + launch screen
Xcode auto-generates a number of files when we start a new App project. Among these is the “Assets” file.
Let’s open it up and check out the “App Icon” section. We should see a whole bunch of grey boxes.
I’ll drag a 1024×1024 pixel image of the Le Wagon logo I have stored on my computer into the boxes. I don’t have a darker version of the logo, but it would be best if I customized the logo for when users have their home screens in dark or tinted mode.
Now if we push the “Build” button (which looks like a play symbol), then we should see my brand new app icon show up on the home screen of a phone simulator.
But, when we open it up, we’ll only see white! So, let’s customize our launch screen. First, we’ll take any image from my computer that we want to use and drag and drop it to the Assets.
We’ll name it LaunchScreenImage. Then we’ll open the automatically generated file called LaunchScreen. I’ll add my image to the screen.
Then, we’ll add two constraints to center it by putting 0 in both the horizontal and vertical alignment constraints.
And we’ll add some constraints to make it fill the whole phone screen. You may have to play around with these numbers a little depending on the size of your image.
Now, when we build and open the app in the simulator, it will show our loading image before showing a white screen again 💪
Installing Hotwire Native
Then, we’ll install Hotwire Native by selecting “File 👉 Add Package Dependencies” in the menubar.
We’ll enter https://github.com/hotwired/hotwire-native-ios in the search field and select “Add Package”.
We’ll open up the SceneDelegate file and replace it with this code:
import HotwireNative import UIKit let rootURL = URL(string: “https://www.lewagon.com”)! // REPLACE WITH YOUR URL class SceneDelegate: UIResponder, UIWindowSceneDelegate { var window: UIWindow? private let navigator = Navigator() func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { window?.rootViewController = navigator.rootViewController navigator.route(rootURL) } } |
You can replace the value of rootURL with the URL of your deployed Rails app. Or, you can replace it with http://localhost:3000 and it should work as long as you have a rails server running in a Terminal window somewhere. Then we should have a working mobile app!
What was all of that SceneDelegate code though? You might think of it as similar to a Rails controller, in that it is responsible for all the actions that happen over the lifecycle of a single instance of our app. I say “single instance” primarily because iPadOS supports making multiple windows (“instances”) of an app, but on an iPhone we’d usually only have one instance anyway. And the scene function is triggered when a new instance gets launched. That’s why in that function, we used an instance of Hotwire Native’s Navigator class to load our rootURL. Navigator is a core class of Hotwire Native that manages visiting websites, as well as managing the history of links we’ve clicked on and more. Try clicking on one of the links in your app. You should see the new page swoop in from the right as though it were a new page in a mobile app.
Path Configuration
The default behavior of the Navigator is always to open new pages as new entries in the app’s history stack. And usually, this is the pattern we want! But, there might be certain pages we want to have behave differently.
That’s where the path configuration comes in. It’s a JSON file that let’s you determine the behavior of each URL when the Navigator opens it. You can just add a JSON file directly into the Xcode project, but that means you’d have to recompile the app and submit it to Apple for review every time you want to change a path (which may happen simply when you add a new page on the Rails side). That’s why I strongly recommend hosting this file through your Rails app and having Hotwire Native fetch it remotely.
First, in the Xcode project, let’s add a new file called path_configuration.json. It will only need to have the bare minimum code so the app still works if the internet is slow and it isn’t able to request the full path configuration quickly.
{ “settings”: {}, “rules”: [] } |
Then, let’s add this into our AppDelegate class:
class AppDelegate: UIResponder, UIWindowSceneDelegate { // […] func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { let localPathConfigURL = Bundle.main.url(forResource: “path_configuration”, withExtension: “json”)! let remotePathConfigURL = URL(string: “https://www.lewagon.com/configurations/paths.json”)! // change to your URL Hotwire.loadPathConfiguration(from: [ .file(localPathConfigURL), .server(remotePathConfigURL) ]) return true } // […] } |
This means that, first our app will load the path configuration from our new JSON file, and then it will go to our Rails app to /configurations/paths.json to try to request the full version via HTTP request. So, let’s define that on our Rails app. First, we’ll add the route in config/routes.rb:
# config/routes.rb get ‘configurations/paths’, to: ‘configurations#paths’ |
And, we’ll rails generate controller Configurations and edit it:
# app/controllers/configurations_controller.rb class ConfigurationsController < ApplicationController skip_authorization_check def paths # import config/paths.yml and render it as JSON render json: YAML.load_file(Rails.root.join(‘config/paths.yml’)).to_json end end |
And finally, we’ll make a new config/paths.yml file:
# config/paths.yml settings: screenshots_enabled: true rules: – patterns: – “.*” properties: context: default pull_to_refresh_enabled: true ` – patterns: – “/contact$” properties: context: modal pull_to_refresh_enabled: false |
In the rules section, we can define (via regular expressions), which URL will behave how. default means the “normal history stack”, and routes have a default behavior of getting added on to the history stack. But, we’ve now overridden the behavior on the contact page, so it will show up as a fancy modal.
You can check out the official docs to learn about what all the path configuration options do.
Adjusting Rails Behavior on the Mobile App
In my opinion, one of the most powerful parts of Hotwire Native is that it uses an HTTP header in every HTTP request to tell Rails that the user agent is a native app. And Rails interprets this information to give you the amazing helper hotwire_native_app?, which is usable in any controller action or view. As an example, if you want to hide some piece of the page you can do it in any view like:
<% unless hotwire_native_app? %> <div>HTML TO HIDE ON THE NATIVE APP ONLY</div> <% end %> |
A common situation would be to use this technique to hide a website’s footer, which for whatever reason usually doesn’t feel very “native” when displayed in a mobile app. Some apps may also hide their navbars and replace them with native components (more on that later) or with special native-feeling versions that are still built with HTML and CSS.
You may also have noticed that the title that’s put at the top of each page in the native app is drawn from the HTML <title element in each page’s <head>. You may want to customize this like:
<% if hotwire_native_app? %> <title>Home</title> <% else %> <title>Le Wagon – Change Your Life, Learn to Code</title> <% end %> |
I find that having a CSS-based version of all this can be also helpful. I will usually add the following to the app/layouts/views/application.html.erb layout:
<body class=“#{“hotwire-native” if hotwire_native_app?}”> |
And then the following CSS (maybe to the app/assets/stylesheets/application.css):
body.turbo-native .turbo-native-hidden { display: none; } body.turbo-native .turbo-native-only { display: block; } body:not(.turbo-native) .turbo-native-only { display: none } |
Now, on any element we can add a class like turbo-native-hidden to hide it only on the mobile app.
Dark Mode & UI Enhancements
You may have noticed that the navigation bar at the top doesn’t always look the prettiest because it’s transparent underneath. Let’s make it white when a user’s phone is in light mode and dark when they change their system appearance to dark mode. Let’s add the following to our scene() function in SceneDelegate:
let appearance = UINavigationBarAppearance() appearance.configureWithDefaultBackground() UINavigationBar.appearance().standardAppearance = appearance UINavigationBar.appearance().scrollEdgeAppearance = appearance UINavigationBar.appearance().compactAppearance = appearance |
You can test the different looks by using the “Features 👉 Toggle Appearance” menubar command in the iOS Simulator.
Now, how can we make our website also look good in dark mode? Fortunately, the answer doesn’t require any Swift code. It’s all CSS on the Rails side, since CSS natively supports a media query for dark mode.
Here’s a sample;
body { background: white; } @media (prefers-color-scheme: dark) { body{ background: black; } } |
It’s a fairly simple pattern, fortunately, so it’s up to you to make your site’s dark mode really shine (or maybe not shine too much). And, even better, all the work you put into it will pay off twice, since the dark mode version will also display in the web version if the user’s device is in dark mode.
External Links
It’s important for me to mention that Hotwire Native can only open links that are loaded via Turbo Drive. In Rails 7, this happens by default for all links, so it shouldn’t generally be a huge concern. But, if we turn Turbo off on a link or form (turbo: false), that link will simply not open in the native app, and the app won’t do anything.
What about external links to other websites, which assumedly won’t have Turbo enabled? Fortunately, Hotwire Native has a default behavior of opening the link in a Safari View Controller, which is a little modal of the native web browser that slides up over the app. This is generally a pretty good behavior. For some apps, though, you may prefer to have the links directly open in native Safari and change apps. That can be achieved simply by overriding handle(externalURL:) in SceneDelegate:
func handle(externalURL: URL) -> ExternalURLNavigationAction { return .openViaSystem } |
It’s important to mention here that any external link will be opened in this fashion. This means that Hotwire Native is not compatible with OAuth (like “Sign in with Google”), which requires redirecting the user to an external site that does not use Turbo. In my experience, it did nothing when I tried it in a project. The only way to implement OAuth in a Hotwire Native app (at least at the moment) is to implement a native component in Swift that works using HTTP requests and tokens. That being said, standard email+password login, via Devise or any other authentication framework, works flawlessly with no configuration.
On a related note, you may have noticed that, if you long click on any links, they’ll show a preview popup that opens in Safari instead of in the app. This doesn’t feel very native. So, you may want to turn it off by adding the following into the application() function in AppDelegate:
Hotwire.config.makeCustomWebView = { config in let webView = WKWebView(frame: .zero, configuration: config) webView.allowsLinkPreview = false webView.scrollView.alwaysBounceVertical = true return webView } |
Native Code, Bridge Components, Oh My!
It goes outside of the scope of this getting-started guide, but in your future projects, you may end up exploring Hotwire Native’s integration with native code.
One approach is Bridge Components. These use JavaScript (via Stimulus) to alert Hotwire Native that a Bridge Component has been added when the element appears in the DOM on the website. You can then write Swift code that responds to this event. A common example would be to have the site’s navbar emit an event indicating all the links that it has. The Swift code would then add these links as native buttons to the native app’s screen. Some CSS could then be used to hide the original site navbar. Finally, when the user clicks on one of the native buttons, Swift will send a notification back to the site’s JavaScript, which can be used to click the original link (which is now hidden). It’s a powerful approach for using native iOS interface elements to trigger interactions with the website, but it’s also generally not needed for basic apps.
Another is Native Screens. These can be configured in the path configuration to tell Hotwire Native to replace an entire webpage with native Swift code instead when the user navigates there. As a result, our app can mix HTML and native views seamlessly. Coding a native screen, however, requires Swift knowledge, so it probably won’t be the domain of the average Rails developer.
Conclusion
Hotwire Native is an exciting new technology that, with relatively little code, can convert an existing Rails website into a native iOS (and also Android) app. It aims to strike a new balance in writing native apps for web developers, and works significantly differently from alternatives like React Native. Instead of compiling JavaScript to native code, it relies on the existing website and works similarly to a web browser but looks and feels more native. As a result, it offers nearly immediate feature parity with an existing website (which you’ll know is a huge deal if you’ve ever tried to build a mobile app from scratch!). But, not all web pages will feel immediately “native”, so it provides a series of helpful tweaks and techniques that can essentially fool users into thinking they’re using an entirely native app, rather than a series of web views. Having migrated a React Native app to Hotwire Native myself, I can say the result was radically simpler and more performant, which is a huge win for small development teams. It’s definitely an exciting resource for Rails developers, who can now easily spin out mobile apps instead of just PWAs. And, though I haven’t touched on it here, I encourage you to try it with Android too.
And, if you’re looking to dive deeper, I recommend the official docs and Joe Masilotti’s site.