DOCS LLMs

RailsFast Native - Server

RailsFast Base is the server contract for the native apps. This is the page where a Rails dev learns how their existing app already drives both mobile shells: what routes exist, what JSON the path-config endpoints serve, how the shell states and auth handoff work, and where the bridge markup comes from.

That one sentence explains almost everything:

RailsFast Base is where the product-level native rules live.

The Android and iOS repos stay deliberately thin. If something should be remotely changeable without shipping a new mobile binary, it almost certainly belongs here. Read Native Overview first for the mental model; this page is the contract underneath it.

The Native Surface In RailsFast Base

The native surface in RailsFast Base has these parts:

  1. Native routes (and the well-known files that live next to them)
  2. Layout and HTML markers
  3. Native-safe layout primitives
  4. Path configuration (the routing source of truth)
  5. Shell states and the entry/handoff flow
  6. Bridge contracts
  7. Auth, session persistence, and the CSRF rule for writes
  8. Billing policy
  9. Verified app-link files

1. Native Routes

RailsFast Base exposes these native-specific routes:

# config/routes.rb
get "native/entry",    to: "native/entries#show",   as: :native_entry
get "native/handoff",  to: "native/entries#handoff", as: :native_handoff
get "native/auth/welcome", to: "native/auth#show",   as: :native_auth_welcome
get "native/me",       to: "native/me#show",         as: :native_me
get "native/configurations/ios/v1",     to: "native/configurations#ios_v1"
get "native/configurations/android/v1", to: "native/configurations#android_v1"
post   "native/push_tokens", to: "native/push_tokens#create", as: :native_push_tokens
delete "native/push_tokens", to: "native/push_tokens#destroy"

These five controllers (entries, auth, me, configurations, push_tokens) live under app/controllers/native/. native is its own RailsFast subsystem, so it gets its own namespace and folder. The push token endpoint is covered in Notifications.

There are also three machine-readable well-known files. These are part of the same contract, but they do not live under native/: they resolve to a top-level WellKnownController:

# config/routes.rb
get "/.well-known/railsfast.json",                to: "well_known#railsfast"
get "/.well-known/assetlinks.json",               to: "well_known#assetlinks"
get "/.well-known/apple-app-site-association",    to: "well_known#apple_app_site_association"
get "/apple-app-site-association",                to: "well_known#apple_app_site_association"
NOTE

/native/entry and /native/handoff are two different routes on purpose, and the split is core to how auth works. /native/entry is the cold-launch and sign-out bootstrap; /native/handoff is where auth success lands. The whole flow is explained in Shell States And The Entry/Handoff Flow below. Read that section before you touch auth.

2. Layout And HTML Markers

RailsFast layouts publish native-aware markers so the same HTML can serve both web and native:

  • html[data-hotwire-native] on native requests
  • Tailwind variants hotwire-native: and not-hotwire-native:
  • the .hide@native utility
  • the shell meta tags railsfast-native-shell and railsfast-native-authenticated

The shell meta tags are emitted in every layout head (only when native) from app/views/layouts/html_head/_native_shell_meta.html.erb, alongside turbo_refresh_method_tag(:morph) and turbo_refresh_scroll_tag(:preserve):

<%# app/views/layouts/html_head/_native_shell_meta.html.erb %>
<meta name="railsfast-native-shell" content="<%= native_shell_state %>">
<meta name="railsfast-native-authenticated" content="<%= native_authenticated? %>">

The philosophy here is the same one in the overview:

Prefer the same HTML tree plus CSS hiding over separate native-only templates.

That keeps web and native aligned and minimizes the amount of divergent UI you maintain.

3. Native-Safe Layout Primitives

RailsFast Base opts native-capable layouts into:

  • viewport-fit=cover
  • html[data-hotwire-native]
  • horizontal overflow containment
  • native-specific Tailwind variants
  • root vertical scrolling that stays touch-scrollable on Android WebView

The root CSS contract is deliberately conservative:

  • suppress horizontal overscroll and accidental horizontal page drift
  • keep overscroll-behavior-y: auto
  • let fullscreen pages own safe-area padding with env(safe-area-inset-*)
  • avoid hiding vertical document scrolling globally
WARNING

The vertical overscroll point is load-bearing. Some Android WebView builds stop touch-driven tab-root scrolling when root vertical overscroll is disabled globally. Pages that truly need a locked, app-like surface should opt in locally instead of making every native page non-scrollable.

Key files:

  • app/assets/tailwind/application.css
  • app/views/layouts/application.html.erb
  • app/views/layouts/dashboard.html.erb
  • app/views/layouts/devise.html.erb
  • app/views/layouts/native_handoff.html.erb

4. Path Configuration

Path configuration is the single source of truth for native routing. It is served per-platform:

  • /native/configurations/ios/v1.json
  • /native/configurations/android/v1.json

Both endpoints return the same shape, { settings: {...}, rules: [...] }, from app/controllers/native/configurations_controller.rb. The settings block is byte-identical across platforms; only the rules[] array differs.

Tab policy lives in settings.shell

The settings.shell block owns tab policy: the default tab, which tabs are visible, each tab's canonical start path, and the route-family regexes that decide which tab owns which URL. This is shared SSOT: the native binaries' compile-time tab catalogs (Android MainTabs.kt, iOS Tabs.swift) must stay identical to it or cross-tab routing silently sends users to the wrong tab.

The catalog is exactly three tabs, in order:

{
  "settings": {
    "shell": {
      "default_tab": "home",
      "tabs": [
        { "key": "home",     "start_path": "/dashboard",  "visible": true,
          "route_patterns": ["^/dashboard(?:/.*)?$"] },
        { "key": "account",  "start_path": "/native/me",  "visible": true,
          "route_patterns": ["^/native/me(?:/.*)?$"] },
        { "key": "settings", "start_path": "/settings",   "visible": true,
          "route_patterns": [
            "^/settings(?:/.*)?$",
            "^/organizations(?:/.*)?$",
            "^/memberships(?:/.*)?$",
            "^/invitations(?:/.*)?$",
            "^/billing(?:/.*)?$"
          ] }
      ]
    }
  }
}
TIP

The settings tab deliberately owns the whole account/workspace route family (/organizations, /memberships, /invitations, /billing) so those pages stay inside the Settings navigator. A too-narrow list caused classic "open, flicker, close" bugs: the page landed on the Settings navigator but the shell misclassified it as Home.

Because Rails serves this, you can change route and tab ownership without an App Store / Play review, as long as the installed binary already knows how to render the supported tab set.

iOS and Android rules[] diverge: do not share them

This is the part the contract gets wrong most often. iOS and Android share the same settings/tab catalog but use platform-idiomatic navigation grammar in rules[]:

  • iOS rules use view_controller, modal_style, and presentation. Signup (^/users/sign_up$) uses modal_style: "large" (full-height) because its form is taller (email + password + password confirmation + terms checkbox); sign-in (^/users/sign_in$) uses modal_style: "medium".
  • Android rules each carry a uri/fallback_uri (hotwire://fragment/web, hotwire://fragment/native/me, hotwire://fragment/web/modal/auth-sheet), need a leading .* web-fragment rule, default pull_to_refresh_enabled: true on the catch-all, and ignore modal_style entirely: signup and sign-in collapse into one auth-sheet rule that always opens expanded.
CAUTION

Do not assume a shared rules[] payload. modal_style is iOS-only; the uri/fallback_uri deep links and the leading .* rule are Android-only. Keep the per-platform generation in configurations_controller.rb in sync only on the shared settings block.

/legal/terms and /legal/privacy present as sheets on both platforms (native_legal_sheet_rules; Android maps them to hotwire://fragment/web/modal/sheet). Add a line there when you add another /legal/* page.

The endpoint stays at v1

Both shells compile /native/configurations/<platform>/v1.json into the binary and cache the last good response under that URL. A failed response is ignored and the cached copy stays in force. So:

  • Change v1 in place. An edit reaches every installed build on its next fetch. That is the normal way to change native behaviour without an app release.
  • Renaming the endpoint invalidates nothing. Installed builds keep asking for v1; if it stops answering 200 they keep their last cached copy.
  • Stay readable by old builds. A build ignores properties it does not know, so add new properties rather than changing what an existing one means. A real v2 means shipping shells that request it and serving v1 alongside it for as long as old builds exist.

iOS-only screen properties

RailsFast iOS reads three properties from ios_v1 rules. Android ignores them, so put them in the iOS rules only:

Property Effect
stable_fixed_dock: true keeps the web view below an opaque navigation bar with no automatic content inset, so a position: fixed; bottom: 0 dock is tappable from the first paint. Applied on every iOS version from RailsFast iOS 1.1.0 (older builds only on iOS 26). Pair it with the dock_screen component (Safe areas & fixed docks).
show_modal_done_button: false removes the Done button the shell puts on modal screens and shows a back chevron that dismisses the modal. Use it on task modals where "Done" would read as "save". Only a JSON false counts.
disable_interactive_pop_gesture: true turns off the left-edge swipe-back on that screen, for pages whose own content starts with a horizontal drag (a slide-to-confirm track, a carousel). The back button keeps working. Only a JSON true counts.

The shell reads them while it sets the screen up. If such a route can be the first screen of a cold launch (a deep link or a notification tap), mirror the property in the iOS app's bundled RailsFast/Resources/path-configuration.json too, or the first paint happens before the server's value arrives. The template ships no route with these properties; the full rationale is in the template's docs/native/server-contract.md.

Visits from a modal stay on their tab

Both shells move a visit to the tab whose route_patterns claim its URL, except when the visit starts in a modal. A link or a form redirect from a modal to a page below another tab's root dismisses the modal and lands on the current tab's stack (or stays in the modal when the destination is itself a modal route). A visit to a tab's root, including the modal-close sentinel /dashboard?modal_close=1 from native_modal_close_url, still switches to that tab and resets it.

The reason is that taking a visit over means cancelling Hotwire's proposal, and a cancelled form-submission redirect leaves the modal open with a spinner. When a modal flow should end on a page another tab owns, redirect to a page the current tab can show (a confirmation with a link onwards) instead of counting on a tab switch. Shells built from templates older than 1.1.0 may still take such a visit over.

5. Shell States And The Entry/Handoff Flow

RailsFast Base publishes exactly three shell states as product semantics (not Android-only details):

  • auth: signed out, no tabs
  • onboarding: signed in but setup must still run, no tabs yet
  • tabs: signed in and inside the app, with native tabs

Rails decides which state the current page belongs to; native reacts. The predicate is tiny:

# app/controllers/application_controller.rb
def native_shell_state_for(user)
  return "auth" unless user
  user.native_onboarding? ? "onboarding" : "tabs"
end
# app/models/user.rb
def native_onboarding?
  !belongs_to_any_organization?
end

User#native_onboarding? is intentionally small: a user with no organization is still in setup. It is the RailsFast default, not a universal truth. If your app has a richer setup flow, this is the method to evolve.

How the state reaches native

The state is published two ways:

  1. A <meta name="railsfast-native-shell" content="auth|onboarding|tabs"> tag, emitted on every native page.
  2. A hidden shell bridge div (data-controller="railsfast--native--shell" with data-bridge-state), emitted only when render_native_shell_bridge? returns true.
IMPORTANT

Signed-out /users/sign_in and /users/sign_up emit the auth meta tag but must not emit the bridge div. render_native_shell_bridge? returns false for the auth sheets. Only the canonical signed-out roots (/native/entry, /native/auth/welcome) may drive auth shell transitions. Emitting auth bridge state from inside the auth sheet made Android reopen and reshuffle the signed-out shell after a real sign-in had already happened.

The entry/handoff flow

/native/entry (EntriesController#show) is the canonical signed-out bootstrap:

  • Signed out → renders the web welcome screen (native/auth/show).
  • Signed in → renders a signed-in handoff document instead of redirecting to /dashboard or /organizations/new.

/native/handoff (EntriesController#handoff) is a separate route used after auth success. It redirects back to /native/entry if the session is gone, otherwise renders the same signed-in handoff.

WARNING

Auth success redirects to /native/handoff, never /native/entry. /native/entry is reserved for cold launch and sign-out. Mixing them caused Android modal-dismiss + replace-root churn that left the app stuck on a spinner after repeated sign-out/sign-in loops. SessionsController#create redirects (303) to native_handoff_path precisely because success originates inside a modal sheet, and modal-to-modal replace stalled Android.

Both the handoff and the signed-in branch of /native/entry render a deliberately blank document with the native_handoff layout. The view body is just an empty white div:

<%# app/views/native/entries/show.html.erb %>
<% title "" %>
<% description "" %>
<div class="min-h-[100dvh] bg-white" aria-hidden="true"></div>

Its only payload is the hidden shell bridge plus an optional data-bridge-location (computed by native_signed_in_entry_path_for: /organizations/new for onboarding users, /dashboard otherwise). A redirect can't carry that bridge DOM, and rendering real signed-in content would flash inside the wrong native container before the root swap completes.

6. Bridge Contracts

RailsFast Base renders the native bridge markup; Android and iOS implement the native side. The governing rule:

Gate bridge output by component support, not by platform.

Hotwire Native appends a bridge-components: [...] segment to the user agent after the native app registers its components. The helpers you call to act on that are in app/helpers/hotwire_native_helper.rb:

  • hotwire_native_bridge_components: parses the UA (/bridge-components:\s*\[(.*?)\]/) into an array.
  • hotwire_native_bridge_component_supported?("toast"): the gate every bridge keys off.
  • hotwire_native_platform / hotwire_native_android_app? / hotwire_native_ios_app?: for platform quirks only, never capability gating.
CAUTION

Detection is by advertised component, not OS. A plain Hotwire Native iOS user agent with no bridge-components segment gets no toast and keeps HTML flash; toast only renders when toast is in the advertised list. Do not gate bridge features on iOS vs Android.

The bridge components

RailsFast Base ships these web halves. Each needs a specific Rails controller, route or helper, so they stay template primitives:

Component Server origin Native receives
toast _toast_bridge_tag.html.erb + toast_controller.js show {message, severity}
native-shell _shell_bridge.html.erb + shell_controller.js connect {state, handoff, location?}
theme _theme_bridge.html.erb + theme_controller.js the light/dark/system preference (see Dark mode)
menu _overflow_menu.html.erb + menu_controller.js display {title, items: [{title, destructive, index}], source}, reply {selectedIndex}
overflow-menu _overflow_menu.html.erb + overflow_menu_controller.js connect {label}
share railsfast--ui--share (plain Stimulus, works on the web too) share {title?, text?, url?, metadata}
native-push _push_bridge.html.erb, _push_settings_row.html.erb + push_controller.js register {request_permission}, open_settings (see Notifications)

native-push-settings has no web controller of its own. It is a capability signal: a shell that advertises it may receive a passive register and an "Open settings" button.

The share one is special: it is not a BridgeComponent subclass on the web side, because it must keep working outside native shells. It degrades from the native share sheet to the browser Web Share API to clipboard copy. Native shells present UIActivityViewController / the ACTION_SEND chooser.

The toast payload is shared across platforms:

{
  "message": "Signed in successfully",
  "severity": "success"
}

Severity vocabulary is success, error, warning, info. Native maps it to the platform primitive (Android Toast vs Snackbar; iOS capsule banner).

Composition and ordering

_bridge_components.html.erb renders the toast bridge, then the theme bridge, then the shell bridge, then the push bridge:

<%# app/views/native/_bridge_components.html.erb %>
<%= render "native/toast_bridge" %>
<%= render "native/theme_bridge" %>
<%= render "native/shell_bridge" %>
<%= render "native/push_bridge" %>
IMPORTANT

The order is contractual. The sign-in / sign-out toast and the theme must be sent before the native root is swapped, so both render before the shell bridge. This partial is included in the application, dashboard, devise, and native_handoff layouts. The push bridge renders at most once per request, so a layout that renders the partial twice still gets one push controller.

The server-owned overflow menu

native/_overflow_menu.html.erb is a reusable, hidden, server-owned menu. It renders nothing on the web and nothing when it has no items. On a native page it renders a menu controller, an overflow-menu button (the native ⋯ icon) and one hidden element per item through native/_menu_item: a link for a plain GET, a form for everything else, so confirm: works for every method.

Items are hashes with label, url, and optionally method, params, confirm and destructive. destructive: true reaches the shell as "destructive": true and the shell draws the row in its destructive style (iOS UIAlertAction.Style.destructive, Android the theme's error colour); "ios" or "android" marks it on one platform only. It is presentation only: the reply is still the item's index. Shells built before 1.1.0 ignore the key.

_settings_actions.html.erb uses it for the Settings page menu, whose only item is Sign out, drawn as destructive. That is how logout stays server-driven: the native side presents the affordance, the web page stays the source of truth for what the action does. See Add an item to the native menu.

Toast vs HTML flash

The toast bridge and the HTML flash fallback are mutually exclusive, so you never get double UI:

  • html_flash_messages = displayable flash minus the keys the toast bridge claimed.
  • Web clients and native-without-toast keep normal Rails flash HTML.
  • Native-with-toast suppresses the HTML flash container for bridged keys and emits the toast instead.

Two details to preserve when editing:

  • Devise's unauthenticated alert (devise.failure.unauthenticated) is explicitly blocked from the bridge by native_toast_message_bridgeable?: it still appears in the HTML body, never as a native toast. Only flash keys in %w[notice success alert error danger warn warning info] cross the bridge.
  • RailsFast Base also bridges model validation summaries (_model_errors_toast_bridge.html.erb) straight off resource.errors when toast is supported. A single error shows verbatim; multiple errors use the I18n string railsfast.native.model_errors.summary (so downstream apps can localize). The top error rollup is hidden only when toast is supported and there are no :base errors.
NOTE

The bridge ERB partials use if/end, never a top-level ERB return: a top-level return renders nil, which ActionView::TestCase can't append. Keep that pattern.

Key bridge files:

  • app/helpers/hotwire_native_helper.rb
  • app/views/native/_bridge_components.html.erb
  • app/views/native/_toast_bridge.html.erb, _toast_bridge_tag.html.erb, _model_errors_toast_bridge.html.erb
  • app/views/native/_shell_bridge.html.erb
  • app/views/native/_theme_bridge.html.erb
  • app/views/native/_overflow_menu.html.erb, _menu_item.html.erb, _settings_actions.html.erb
  • app/views/native/_push_bridge.html.erb, _push_settings_row.html.erb
  • app/views/devise/shared/_error_messages.html.erb, _form_input.html.erb
  • app/javascript/controllers/railsfast/native/{toast,shell,theme,menu,overflow_menu,push}_controller.js
  • test/lib/bridge_component_names_test.rb (component names must avoid bot-like words; see naming bridge components)

7. Auth, Session Persistence, And CSRF

RailsFast Native uses the same Devise cookie auth system as the web app:

  • no separate JWT stack
  • no separate mobile auth API
  • the same Rails session
  • the same rememberable cookie

Rememberable is auto-enabled for native

Web keeps remember-me as an explicit opt-in checkbox. Native auto-enables it with no visible checkbox: the sign-in form renders a hidden user[remember_me]=true field for native, and SessionsController#create calls remember_hotwire_native_session, which returns early unless hotwire_native_app? && devise_mapping.rememberable? and otherwise calls remember_me(resource). The cookie persists same_site: :lax, remember_for 1.year (see config/initializers/devise.rb).

Native auth keeps Turbo enabled (this is what makes the handoff work)

# app/helpers/hotwire_native_helper.rb
def devise_auth_form_data_attributes
  hotwire_native_app? ? {} : { turbo: false }
end

Web auth forms set data-turbo="false"; native forms keep Turbo enabled so a successful POST stays on Turbo's POST → 303 → GET path, and Hotwire Native turns that redirect into a managed visit to /native/handoff. This is the mechanism behind the entry/handoff flow in section 5.

The manual native sign-in flow

On bad credentials, native sign-in does not fall through to the Devise failure app (which produced 401/422 error screens inside the sheet). SessionsController#create re-renders :new with 422 and a flash.now[:alert], and resets self.resource = resource_class.new so the next submit stays a POST (otherwise form_for would switch it to PATCH /users/sign_in, which has no route). Preserve this branch when editing auth.

The custom Devise failure app

# lib/devise/current_host_failure_app.rb
class CurrentHostFailureApp < FailureApp
  def route(scope)
    :"new_#{scope}_session_path"   # path helper, NOT the stock URL helper
  end
end

config.warden.failure_app = Devise::CurrentHostFailureApp returns the path helper instead of stock Devise's absolute new_*_session_url. Stock Devise falls back to default_url_options[:host] (localhost in dev), which bounced Android emulator traffic arriving via 10.0.2.2 back to localhost and broke signed-out deep links. Reverting to the stock failure app re-breaks emulator deep links.

CSRF: the required contract for native writes

WARNING

Cookie sync solves authentication continuity, not CSRF. The shared session cookie lets native screens read authenticated pages, but Rails still enforces CSRF on every unsafe method. The template has no native-code write endpoint: /native/me is GET-only, and the one native write, POST /native/push_tokens, is sent by a Stimulus controller inside the WebView, so it carries the page's CSRF token like any form. When you add an endpoint that Swift or Kotlin code calls directly, pair it with an explicit CSRF policy. The blessed pattern is skip_forgery_protection scoped to JSON for authenticated, native-only endpoints (see the CSRF write contract). Do not assume cookie forwarding alone enables writes.

The /native/me account screen

/native/me (MeController#show) is the canonical native Account screen, served as JSON. It requires a signed-in user:

  • JSON when signed out → 401 (so native bounces to the auth shell).
  • HTML → redirects to settings_path (fallback for stale links).

The JSON payload both native shells consume:

{
  "user":         { "email": "...", "confirmed": true, "created_at": "<iso8601>" },
  "organization": { "name": "..." },
  "account":      { "onboarding": false, "plan_name": "...", "credits": null }
}

8. Billing Policy

RailsFast treats native as a companion app. The web billing flows are not part of the native app: they redirect back into the shell:

  • /pricing, /subscribe, and /billing all redirect for native requests. SubscriptionsController runs before_action :redirect_native_billing_flow!, if: :hotwire_native_app?.
  • The destination is native_companion_app_redirect_path: signed-out and onboarding users go to /native/entry, signed-in users go to /settings.
  • Pending Stripe checkout intent is discarded for native (pending_checkout_redirect_path returns nil), so a stale web checkout doesn't drop the user into Stripe after auth.

This is deliberate. Native billing is a product and policy decision. Don't half-enable it by accident until you have an App Store / Play billing strategy.

RailsFast Base owns the server-side files that let native apps claim production HTTPS URLs, served by WellKnownController:

  • Android: /.well-known/assetlinks.json
  • iOS: /.well-known/apple-app-site-association and the byte-identical root alias /apple-app-site-association
  • The RailsFast fingerprint lives in the same controller: /.well-known/railsfast.json (returns { "railsfast": true, "railsfast_version": "v1.5.0" }, the version being your app's Railsfast::TEMPLATE_VERSION).

The app-link path list lives in config/railsfast/railsfast.yml under native.ios.app_link_paths; that YAML list is the source of truth. With no configured Team ID, bundle ID, app IDs, or paths, the builder returns a valid non-claiming payload:

{
  "applinks": {
    "apps": [],
    "details": []
  }
}

That fail-closed behavior is deliberate: a template must not publish a fake Apple app claim. Android assetlinks.json similarly returns [] until both a package name and at least one SHA-256 fingerprint are configured.

Four links exist for people who are not signed in yet, and the shells open them on the signed-out screen instead of holding them until sign-in: the password-reset link (/users/password/edit?reset_password_token=...), the confirmation link (/users/confirmation?confirmation_token=...), the unlock link (/users/unlock?unlock_token=...) and organization invitations (/invitations/:token). The default app_link_paths already claim /users/* and /invitations/*; keep them when you trim the list, or these emails open in the browser instead of the app.

For production, configure either:

IOS_APP_LINK_TEAM_ID=ABCDE12345
IOS_APP_LINK_BUNDLE_ID=com.example.ios

or:

IOS_APP_LINK_APP_IDS=ABCDE12345.com.example.ios,ABCDE12345.com.example.ios.beta

You can override the claimed path set without touching YAML:

IOS_APP_LINK_PATHS=/,/dashboard,/dashboard/*,/settings,/settings/*

Cache headers

IMPORTANT

All three well-known actions call expires_in 12.hours, public: true. But assetlinks and apple_app_site_association additionally set response.cache_control[:extras] = ["no-transform"], because app-link verifiers are strict and CDN or proxy body rewrites can make a correct payload fail verification. You cannot pass no-transform: true to expires_in: that emits the invalid no-transform=true. The railsfast.json fingerprint endpoint shares the controller but does not need the no-transform directive.

Useful references:

User-Agent Layering

hotwire_native_app? is not RailsFast code: it comes from turbo-rails (Turbo::Native::Navigation) and matches /(Turbo|Hotwire) Native/ in the user agent. If you bump turbo-rails or change the UA string, native detection silently shifts.

Both shells put their own prefix in front of what Hotwire adds:

{AppName}/{version} ({device}; Android {os}; build {build}); {AppName} Android; RailsFast Native Android; Hotwire Native Android; Turbo Native Android; bridge-components: [...]

(iOS is the same with iOS in place of Android.) What Rails reads from it:

  • hotwire_native_app?: any Hotwire Native app (gem-provided).
  • hotwire_native_platform / hotwire_native_android_app? / hotwire_native_ios_app?: the platform, from the Hotwire Native Android / Hotwire Native iOS segment. Use them for platform quirks only.
  • hotwire_native_bridge_components: the advertised components, which gate every bridge.
  • The leading {AppName}/{version} (...) part follows the sessions gem's convention, so the Sessions & devices page shows the app version, build and device model of each signed-in phone.

RailsFast Native {Platform} and {AppName} {Platform} are identity for your logs; no template code branches on them. Keep the layered pattern intact and never duplicate the Hotwire Native strings in your prefix.

Where To Customize RailsFast Base

You want to change Edit
The native welcome screen (copy, layout, branding) app/views/native/auth/show.html.erb
When users stay outside the tab shell User#native_onboarding? in app/models/user.rb
Where users land after sign-in native_signed_in_entry_path_for in app/controllers/application_controller.rb
Tab ownership, modal rules, auth-sheet rules, settings.shell app/controllers/native/configurations_controller.rb
The /native/me JSON payload app/controllers/native/me_controller.rb
The native ⋯ menu on a page render native/_overflow_menu with your items
Push credentials and the Firebase project id credentials action_push_native, ANDROID_FIREBASE_PROJECT_ID (Notifications)

What Should Not Live Here

These belong in the Android or iOS repo, never in RailsFast Base:

  • Android fragment classes, sheet sizing, transition suppression
  • iOS controller wiring, the two-shell root swap, tab bar icons
  • app icons, adaptive-icon layers, launch-screen images, and native splash themes
  • any Kotlin or Swift bridge logic

RailsFast Base describes the product contract. It does not absorb native container code.