How to build a Rails app that’s ready for the app stores
The five rules I'd hand to my past self to save months of mobile-prep refactoring.
Hey friends,
A reader asked me a great question this week:
Is there a good source for best practices in a Rails app to prepare it for going native?
They gave two examples. One small: skipping Turbo to fix an annoying redirect on the web, which then breaks page transitions on mobile. One big: realizing his web app has a dozen top-level paths, but the mobile tab bar can only hold five.
I’ve seen this pattern in almost every Rails codebase I review for clients. The code isn’t wrong. It’s just web-first in ways that make the mobile version harder than it needs to be.
The good news: most of this is about decisions, not architecture. Make them early and you save yourself a rewrite six months in. This applies whether you ship with Hotwire Native or Ruby Native.
Here are the five rules I’d hand to my past self.
1. Design your information architecture around 3-5 entry points
Apple’s Human Interface Guidelines and Google’s Material guidelines both recommend 3-5 tabs. In practice, this isn’t a suggestion, it’s a constraint that shapes your entire app.
Here’s what that actually looks like in practice. Say your web app has this top nav:
Dashboard · Projects · Reports · Teams · Billing · Notifications · Settings · Admin · Help
That’s nine items but the mobile apps can show at most five. So before you write a single controller, you have to decide which get the highest real estate. And which are demoted behind a “More” tab or link.
The constraint is a feature, not a limitation. Mobile apps work best when they’re focused. The web can afford to be a catch-all because users are sitting at a desk with a mouse, skimming a full nav bar. A phone user tapping through one-handed on a subway needs the app to pick a lane. The five-tab limit forces you to answer “what is this app for?” in a way that a web nav never does.
This exercise feels boring. But it’s the most important one you’ll do. Every URL you design, every redirect you wire up, and every nav partial you build should reinforce those five destinations.
If you skip this step and just “make the web nav work on mobile,” you’ll end up with a hamburger menu that hides everything, which is exactly the experience native users hate.
The test: Can a user reach each core feature in 2 taps from the tab bar? If yes, you’ve got the right architecture.
2. Lean into Turbo from day one
The reader’s example was perfect: They disabled Turbo to fix a rendering issue on the web, which then broke mobile page transitions.
This happens constantly. A flash message doesn’t show up after a form submit, or a JavaScript library doesn’t play nice with Turbo’s fetch, so the developer reaches for the fastest fix they can think of:
<%= button_to “Delete”, post_path(@post), method: :delete, data: { turbo: false } %><%= link_to “Dashboard”, dashboard_path, data: { turbo: false } %>On the web, this “works.” The flash shows, the JavaScript library loads, and the bug goes away. On mobile, this request breaks a transition or is ignored entirely. Hotwire Native needs Turbo to navigate. Without it, link taps silently fail.
The right move is to fix the underlying issue, not mask it. If the flash isn’t showing, respond with a Turbo Stream that updates it:
def create
@post = Post.new(post_params)
if @post.save
respond_to do |format|
format.turbo_stream { flash.now[:notice] = “Post created” }
format.html { redirect_to posts_path, notice: “Post created” }
end
else
render :new, status: :unprocessable_entity
end
endThe mental model shift: Turbo isn’t a web library, it’s the navigation layer. The web just happens to be one of the clients. Reach for data-turbo="false" and you’re papering over a web bug at the cost of the mobile experience.
The exception: The only time you want to disable Turbo navigation is with external POST requests, like Stripe checkout options. But those should be handled on their own, with fully native code. Or converted to in-app purchases.
3. Stick to RESTful, predictable URLs
Hotwire Native uses path configuration to decide how each screen should be presented. Modals, replacements, refreshes, external links… they are all pattern-matched against the URL.
Here’s a typical path configuration that relies on Rails conventions:
{
“rules”: [
{
“patterns”: [”/new$”, “/edit$”],
“properties”: { “context”: “modal” }
},
{
“patterns”: [”/sign_in”, “/sign_up”],
“properties”: { “presentation”: “replace_root” }
}
]
}Those two rules handle 90% of a CRUD app. Every form shows as a modal. Auth screens replace the whole stack. Everything else pushes onto the navigation.
But this only works if your routes look like this:
resources :posts
resources :profilesWhich gives you predictable URLs like /posts/new, /posts/123/edit, /profiles/edit. The $ anchors in the patterns above catch them all.
Now imagine your routes look like this, which is what I actually see in production Rails apps:
# “Friendly” auth routes
get “signup”, to: “users#new”
post “signup”, to: “users#create”
get “login”, to: “sessions#new”
get “account”, to: “users#edit”None of /signup, /login, or /account end in /new or /edit. They won’t match your modal pattern. The signup form pushes onto the nav stack instead of presenting as a modal. Login pushes on top of whatever screen the user was on, instead of replacing the root.
You can fix it with custom patterns:
{
“patterns”: [”/signup”, “/login”, “/account”],
“properties”: { “context”: “modal” }
}But now you’re maintaining two parallel systems: Rails convention for some screens, bespoke patterns for others. Every new “friendly” URL is another rule. Every refactor risks a mobile regression.
The rule is simple: resources :things, every time. Your future mobile self will thank you.
4. Gate mobile-specific UI early
You will have UI that shouldn’t appear in the mobile app:
Your web navbar (the native tab bar replaces it)
Your marketing footer
A “Download our iOS app” banner (Apple will actually reject apps with this!)
Cookie consent popups
Chat widgets that don’t play nice with WKWebView
The cheap time to handle this is before you ship. The expensive time is after, when you’re untangling a view hierarchy built around a web-only layout.
Rails ships with hotwire_native_app? through turbo-rails. It returns true when the request’s User-Agent includes “Hotwire Native.” Use it from day one.
For one-off conditionals in views:
<% unless hotwire_native_app? %>
<%= render “marketing_footer” %>
<% end %>For larger chunks of layout, wrap whole partials:
<%# app/views/layouts/application.html.erb %>
<body>
<%= render “web_header” unless hotwire_native_app? %>
<%= yield %>
<%= render “web_footer” unless hotwire_native_app? %>
</body>For dozens of scattered elements, it’s cleaner to load a mobile-only stylesheet:
<%# app/views/layouts/application.html.erb %>
<head>
<%= stylesheet_link_tag “application” %>
<%= stylesheet_link_tag “native” if hotwire_native_app? %>
</head>/* app/assets/stylesheets/native.css */
.d-hotwire-native-none {
display: none !important;
}Then mark anything you want hidden:
<nav class=”d-hotwire-native-none”>
<%# web-only nav %>
</nav>Now hiding a new element in the mobile app is a one-class change, not a refactor.
The pattern that scales: Every time you add a web-specific element (banner, popup, footer link), ask if it should appear in the app. If not, add the class or wrap it immediately. The cost of doing this as you go is nothing. The cost of doing it in a panicked pre-launch sprint is a week.
5. Treat authentication as “signed in forever”
On the web, session cookies expire. Users sign back in. Nobody cares.
On mobile, making someone sign in every time they open the app will destroy your ratings. “I have to log in every time” is the kind of review that kills new apps. Design for signed-in-forever from the start.
The mobile web views persists cookies automatically, so this mostly comes down to making the server issue long-lived sessions. With Devise, programmatically check remember_me for mobile users:
<%# app/views/devise/sessions/new.html.erb %>
<%= form_for(resource, as: resource_name, url: session_path(resource_name)) do |f| %>
<%= f.email_field :email %>
<%= f.password_field :password %>
<% if hotwire_native_app? %>
<%= f.hidden_field :remember_me, value: true %>
<% else %>
<%= f.check_box :remember_me %>
<%= f.label :remember_me %>
<% end %>
<%= f.submit “Sign in” %>
<% end %>And bump the remember-me duration to something reasonable for a mobile app:
# config/initializers/devise.rb
config.remember_for = 1.yearIf you’re rolling your own auth (the Rails 8 generator, for example), the same principle applies. Use permanent when assigning the session cookie so it lasts 20 years instead of expiring with the browser session:
class SessionsController < ApplicationController
def create
if user = User.authenticate_by(session_params)
session = user.sessions.create!
cookies.signed.permanent[:session_id] = session.id
redirect_to root_path
else
redirect_to new_session_path, alert: “Invalid email or password”
end
end
endThe mobile WebView persists permanent cookies across app launches, so once the user signs in they stay signed in until they explicitly sign out.
Build like the mobile app is coming
Two years ago mobile apps were an item on the “nice to have” checklist. Today, they are almost mandatory.
AI coding assistants compress weeks of work into hours. The flip side is that you can also paint yourself into a corner just as quickly. The reader who asked the question wants to feed these rules into Claude, and that’s exactly right. The constraint isn’t typing speed anymore, it’s the decisions embedded in the code the AI generates.
Ruby Native recently launching has also shifted the calculus. It’s early, but it makes the “Rails-first, mobile second” story more real than ever. If your Rails app follows conventions, you get a mobile app nearly for free. If it doesn’t, you get a mobile app eventually… after a lot of refactoring.
Want to know how your Rails app scores against these five rules? That’s exactly what I do in the first week of a Mobile Playbook engagement. I pull down your codebase, walk through each rule against the real code, and hand you back a concrete list of what to refactor before the mobile build starts. Plus an architecture diagram, a risk register, and a phased build plan.
Two weeks, fixed price, and you leave knowing exactly what to change. Book a call if you want to talk through whether it’s a fit.

