Hotwire Native Guide
How the Immersive mobile shell works and how to work on it. This is a coding guide, not a tutorial — it assumes you know Rails and describes the system as built, plus the rules and gotchas that keep it working. For quick lookups (API names, property tables, error symptoms) see the reference.
The model
Hotwire Native is a thin native shell that loads pages from the Rails server inside a WKWebView (iOS) / WebView (Android), wrapped in real native chrome: tab bar, navigation bars, modals, swipe-back. The server stays in charge — it serves the HTML, sets each screen’s title via <title>, and serves a path configuration JSON that tells the app which URLs are modals, which replace the navigation stack, and so on. Almost every change ships as a Rails deploy; the binary changes only when the shell itself does.
The standing order of priorities:
- Ship the web version first. Promote to native only when web is genuinely insufficient, and only with usage data in hand.
- Path configuration is the source of truth for navigation. Never hardcode “this URL is a modal” in Swift.
- Drive native UI from HTML —
<title>,data-*attributes, Stimulus opt-ins. Not the other way round. - Business logic stays in Ruby. Native code never duplicates what Rails already does.
Deliberately deferred until the iOS app has shipped and produced usage data: the Android shell, bridge components, and native SwiftUI screens.
What exists where
| Concern | Where |
|---|---|
| Native detection helper | turbo-rails gem; re-exposed in app/controllers/application_controller.rb |
| Layout switch | <html ... data-native="true"> set in app/views/layouts/*.html.erb |
| Native-only CSS | app/assets/stylesheets/native.css |
| Chrome suppression | d-hotwire-native-none on navbars, footers, mobile tab bar, resource headers |
| Path configuration | ConfigurationsController#ios_v1 + config/routes.rb |
| iOS shell | ios/ in the immersive repo (XcodeGen spec + Swift sources) |
| Tab titles (only native strings) | ios/Immersive/App/Resources/<locale>.lproj/Localizable.strings |
Rails side
Detecting the native client
turbo-rails ships hotwire_native_app? (and the legacy alias turbo_native_app?), which matches the User-Agent against /(Turbo|Hotwire) Native/. Hotwire Native appends that token to the UA on every request from the embedded web view — no app-side wiring needed. ApplicationController exposes turbo_native_app? as a helper method; prefer hotwire_native_app? in new code.
Never sniff the User-Agent yourself, and never write ad-hoc native checks — the helper is the canonical test.
The data-native layout switch
Every layout (application, two_column, public, devise) opens with:
<html lang='<%= I18n.locale %>'<%= " #{tag.attributes(data: {native: true})}".html_safe if turbo_native_app? %>>
For web clients the <html> tag is byte-identical to what it was before the shell existed — the attribute only appears for native. That is deliberate: identical HTML for web keeps fragment caches warm and makes the native path purely additive.
native.css and chrome suppression
Immersive’s CSS is a flat directory auto-loaded via stylesheet_link_tag :all, so native.css ships to every client — you cannot conditionally load a stylesheet the way the upstream docs suggest (Propshaft’s :all glob does not skip underscore-prefixed files or subdirectories). Instead, every rule in native.css is gated on the data-native attribute:
[data-native="true"] .d-hotwire-native-none { display: none !important; }
[data-native="true"] .d-hotwire-native-block { display: block !important; }
[data-native="true"] .d-hotwire-native-inline { display: inline !important; }
[data-native="true"] .d-hotwire-native-flex { display: flex !important; }
Web clients never match the selectors. This keeps the flat-CSS convention intact and the HTML identical for both clients.
Hide web chrome with CSS, not Ruby. <% unless hotwire_native_app? %> around a partial serves different HTML per client and breaks fragment caching. Instead:
<header class="site-header d-hotwire-native-none" role="banner">
Chrome the native shell replaces is already marked: _navbar, _public_navbar, both footers, _mobile_tab_bar (the web’s bottom tab bar — the app has a real one), _resource_header, the document reader’s back buttons, and _close_button (conditionally, via its native_hidden local).
Show something only in native by combining the always-hidden display-none utility (from utility.css) with a native override:
<%= link_to "Sign out", session_path,
class: "btn btn-danger display-none d-hotwire-native-block",
data: { turbo_method: "delete" } %>
Page titles
The native nav bar title follows the page <title> on every load. Immersive sets it through MetaTagsHelper#page_title (wrapping content_for(:page_title)), typically via the shared header partial — which also hides its <h1> in native so the title doesn’t appear twice. Set a real title on every screen; users will see the app name on screens that should say “My Decks”.
Permission denials must render HTML
When a Pundit or Devise check fails in a native context, a redirect to sign-in works automatically. head :forbidden or a JSON render leaves the web view blank. Always respond with navigable HTML.
Path configuration
Path configuration is the JSON contract between Rails and the apps: an array of {patterns, properties} rules mapping URL regexes to presentation behavior. The app fetches it on cold start and routes every navigation through it. Served by ConfigurationsController (inherits PublicController — it is fetched before anyone is signed in) at /configurations/ios_v1.json:
{
settings: {},
rules: [
{patterns: ["/users/sign_in$"], properties: {context: "modal", presentation: "replace-root"}},
{patterns: ["/.+/new$", "/.+/edit$"], properties: {context: "modal"}},
{patterns: ["/users/sign_out$"], properties: {presentation: "replace-root"}}
]
}
So: forms and sign-in slide up as modals; sign-in and sign-out wipe the navigation stack (no back button into another user’s screens). When a modal form redirects after a successful submit, the app dismisses the modal and pushes the destination onto the main stack automatically.
Rules that keep this working:
- Rules accumulate top to bottom; later rules override earlier ones on a match-by-match basis.
- Anchor regexes.
/new$— not/new, which also matches/news. This class of bug is silent and confusing. - Version the endpoint, forever. When the schema or semantics change, add
ios_v2, point new builds at it, and leaveios_v1running as long as any shipped build depends on it. Never repoint an existing build at a version it wasn’t written for, and never delete an old version while users are on the corresponding binary. (Configuration.swiftcarries the same warning next to the URL.) - Per-platform endpoints. iOS and Android use different property names for “render natively” (
view_controllervsuri) and Android needs a wildcard rule iOS must not have. When Android arrives it getsandroid_v1, not a shared file. Keep the platforms’ rules in sync unless they genuinely need to differ. - Never hardcode modality in the client. If Swift special-cases a URL’s presentation, path configuration has been defeated.
- The fetched JSON is cached on device. During development, force-quit the app to pick up rule changes.
The full property table and presentation values are in the reference.
The iOS shell as built
ios/ contains the Swift sources, Info.plist, and an XcodeGen spec. The .xcodeproj is a git-ignored build artifact — regenerate it with cd ios && xcodegen generate (re-run after adding or moving any Swift file). project.yml is the reviewable source of truth: bundle id com.immersiveapp.immersive, hotwire-native-ios from 1.3.1, iOS 16 floor (the code uses URL.appending(path:)), iPhone-only, no Main storyboard.
The shell is four small files:
Configuration.swift—baseURLswitches on build configuration: Debug →http://localhost:3000(with an ATS localhost exception inInfo.plist, dev-only), Release →https://immersive-app.com(the apex is the canonical host, per #521). Also definespathConfigurationURL.AppDelegate.swift— callsHotwire.loadPathConfiguration(from: [.server(...)])before any scene connects, so the first visit already knows which URLs are modals. Call it exactly once. (Bridge component registration will live here too.)SceneDelegate.swift— builds theUIWindowprogrammatically and installsTabBarControlleras root.TabBarController.swift+Tab.swift— the tab architecture below.
Tabs and Navigators
Five tabs, each owning its own Navigator and therefore its own navigation stack — switching tabs preserves each tab’s history. On 1.3.1 the API is configuration-based:
let navigator = Navigator(configuration: .init(
name: tab.path, // stable identity — the path, not the localized title
startLocation: baseURL.appending(path: tab.path)
))
Routing happens on first selection only, via navigator.start(). Two gotchas encoded in the controller:
UITabBarControllerDelegate.didSelectdoes not fire for the initially selected tab, soviewDidLoadinvokes it manually for the first tab.- Started-state lives on the
TabBarControllerinstance (startedTabIndexes), not on theTabmodel, so a recreated controller always routes its fresh Navigator stacks instead of inheriting stale flags.
The tabs are Decks, Documents, Grammar, Verbs, Profile — same order as the web primary nav, so muscle memory carries between mobile web and the app. Icons are SF Symbols (system-shipped, nothing to bundle); pick symbols that visually match the web’s Lucide icons. Tab titles are the only user-facing strings that live natively: NSLocalizedString backed by Localizable.strings in all six locales, with keys and translations matching the web’s config/locales entries. Everything else is served by Rails and localized there for free. Keep it that way — any new native string needs all six locales before it ships.
Adding a tab is one entry in Tab.all plus its six Localizable.strings lines. Do not exceed five tabs — iOS overflows into a “More” tab.
What works with zero further Swift
Navigation with native back/swipe-back, nav-bar titles from <title>, cookies persisting across launches, <input type="file"> via the system picker, all Turbo/Stimulus behavior, and modals per path configuration. The fastest dev loop is: change Rails, pull-to-refresh in the simulator — no Xcode rebuild.
Sign-in and cookie persistence
Devise with :rememberable (already enabled) gives the remember-me cookie a long TTL; WKWebsiteDataStore.default() persists cookies across app launches. Together, sign-in survives cold starts with no native code. If auth cookie handling is ever rewritten outside Devise, the requirement is a permanent cookie (cookies.permanent.encrypted) — a session cookie means signing in on every launch.
Sign-out redirects through the replace-root rule, so the stack is wiped and there is no back-swipe into authenticated screens.
One trap for later: URLSession and the WKWebView keep separate cookie jars. If native code ever fetches JSON endpoints that need the session, either copy cookies out of WKWebsiteDataStore.default().httpCookieStore into HTTPCookieStorage.shared, or use token auth for those endpoints.
Bridge components (planned, none built yet)
Bridge components swap an individual HTML control for a native one — e.g. a form’s submit button promoted to a top-right UIBarButtonItem the keyboard can’t hide — without rewriting the screen. Three pieces share one name string:
| Layer | File | Identifier |
|---|---|---|
| HTML | any view | data-controller="bridge--button" |
| Stimulus | app/javascript/controllers/bridge/button_controller.js |
static component = "button" |
| Swift | ios/Immersive/App/Components/ButtonComponent.swift |
override class var name: String { "button" } |
A single typo across the three silently disables the bridge. The Stimulus controller extends BridgeComponent, sends connect with data read from data-bridge-* attributes, and native replies via reply(to:) to fire the JS callback — which should call this.bridgeElement.click() so the original form submit / link follow still happens on the web side rather than being reimplemented natively. Components register in AppDelegate via Hotwire.registerBridgeComponents([...]).
Hard-won rules:
- Always call
super.connect()/super.disconnect()in the Stimulus controller —BridgeComponentregisters the message channel there; forgetting breaks the bridge silently. - Pair every native UI mutation with a disconnect handler. The native button lives outside the web view; when the page navigates away or the element leaves the DOM, Stimulus
disconnect()fires — send it to native and remove the button, or it lingers on the next screen. -
Hide the HTML control conditionally, never unconditionally. The bridge JS stamps
<html data-bridge-components="button">only when the native side registered the component, so:[data-bridge-components~="button"] [data-controller~="bridge--button"] { display: none !important; }Web users and older app builds without the component keep the working HTML control. Progressive enhancement.
- Bridge components are reusable. If it only makes sense on one page, it probably shouldn’t be a bridge component.
Likely first candidates for Immersive, once usage data justifies them: the card-review rating buttons (Again/Hard/Good/Easy as a native control), the submit button on long card-creation forms, and Share deck (native share sheet). Each needs the Stimulus controller, the Swift component, registration, and the native.css hide rule.
Design rules
The bar: the user should not be able to tell which screens are HTML and which are native.
- Default to web. Lists, forms, settings, marketing — everything server-rendered. Each native screen is a maintenance commitment for years. The promotion path is: web in shell → native chrome via path config (done) → bridge components → at most one or two native screens (home dashboard, review session) — and only after TestFlight usage data. Native screens follow the pattern in the archived tutorial chapter 07 (SwiftUI view →
UIHostingController→view_controllerpath rule →NavigatorDelegate.handle(proposal:)→ a.jsontwin of the web URL). - Forms are modals. If it looks like a form,
context: "modal"— the global/new$,/edit$rule handles it.presentation: "replace-root"for auth boundaries. - The web design system already does the right thing for native: system fonts (dynamic type for free — never ship font files), OKLCh tokens and dark mode all render inside the web view untouched, mobile-first layouts. Verify new screens at 375/393/412 px widths.
- Always provide an empty state with a CTA — a blank list inside a native shell reads as an error.
- Don’t reimplement screen transitions in CSS; the shell animates pushes and modals. Avoid CSS
transitions on elements inside a native modal — they fight the modal animation. - No React/Vue in the web view (existing project rule), and no native chrome that mimics HTML chrome — native should be native.
- Mobile-only entry points need designed answers, not defaults: cold-start first screen, push-notification tap target, deep links from email.
Testing and the dev loop
- Capybara + Cuprite system tests cover the HTML the app consumes — keep them green; they are the regression net for the shell even though they never launch it. Native testing, if ever needed, is XCUITest.
-
Simulate the native client from the command line:
curl -A "Immersive/1.0 Hotwire Native iOS" http://localhost:3000/decks | grep data-native curl http://localhost:3000/configurations/ios_v1.json ConfigurationsControllerhas a controller test; extend it when rules change.- Test on a real device before TestFlight — simulators lie about gestures, the keyboard, and performance.
Deployment and TestFlight
Device builds, signing, and TestFlight need Apple Developer Program enrollment ($99/yr) — tracked in #470. The essentials when that happens:
- Xcode target → Signing & Capabilities: set the Team; bundle id
com.immersiveapp.immersivemust match App Store Connect. Automatic signing is fine. - Select Any iOS Device (arm64) (not a simulator) → Product → Archive → Organizer → Distribute App → App Store Connect → Upload.
- In App Store Connect → TestFlight: builds appear ~10–30 min after upload. Internal testers (up to 100) get builds immediately; external testers require a one-time Beta App Review (~24–48 h). Export compliance: “uses standard encryption” (HTTPS) qualifies for the standard exemption.
- Bump
CURRENT_PROJECT_VERSION(inproject.yml) for every upload — the store rejects duplicate build numbers. Use a monotonically increasing scheme (git rev-list --count HEADworks). - Release checklist: production
baseURLis HTTPS with no ATS exceptions beyond localhost-in-debug; path config URL points at production; privacy policy URL live; icons and launch screen in place.
Common Apple rejections to preempt: missing Info.plist usage descriptions for any capability you add (camera etc.); cleartext HTTP in release; and the WebView question — the answer is “the app’s content is rendered server-side as HTML and updates frequently”.
Analytics need nothing new: every visit hits the server, so Ahoy captures app usage; filter by the Hotwire Native User-Agent segment to isolate app-only events. Crash reporting: Xcode Organizer is enough to start.
Android, later
When the Android shell is built, everything Rails-side carries over unchanged — detection, data-native, native.css, chrome suppression, cookie persistence (Android’s CookieManager accepts cookies by default), and the same Stimulus bridge controllers. What is new:
- An
android_v1action inConfigurationsController, following the same versioning discipline. Android’s rules differ in two ways: a wildcard rule (patterns: [".*"]withuri: "hotwire://fragment/web") is mandatory and must come first — without it every URL falls through to nothing — and native screens are selected byuri(matched to@HotwireDestinationDeepLink-annotated fragments) instead of iOS’sview_controller. Disablepull_to_refresh_enabledon modal rules; the pull gesture fights sheet dismissal. - The shell itself:
MainActivity : HotwireActivity()with oneNavigatorHostfragment container per tab (visibility-toggled),dev.hotwire:core+dev.hotwire:navigation-fragmentsartifacts, andHotwireWebFragmentalways registered inregisterFragmentDestinationsso unmatched URLs render as web. - Dev environment quirks: the emulator reaches the host machine at
http://10.0.2.2:3000, notlocalhost; cleartext HTTP is allowed only in the debug manifest; the signing keystore andsecrets.propertiesnever go in git.
The archived tutorial chapters 04 (Android setup), 06 (tabs), and 08 (Compose screens) in git history cover the step-by-step build when the time comes.