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"

These five controllers (entries, auth, me, configurations) live under app/controllers/native/**. That is intentional — at this point native is its own RailsFast subsystem, so it gets its own namespace and folder.

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.

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 five bridge components

RailsFast Base ships five bridge components. All five are template primitives that stay inside RailsFast (they each need a specific Rails controller, route, or helper):

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?}
menu _settings_actions.html.erb + menu_controller.js display {title, items:[{title,index}]} → reply {selectedIndex}
overflow-menu _settings_actions.html.erb + overflow_menu_controller.js connect {label}
share railsfast--ui--share (plain Stimulus, works on web too) share {title?, text?, url?, metadata}

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 shell bridge:

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

Toast-first ordering is contractual. The sign-in / sign-out toast must fire before the native root is swapped, so the toast bridge has to render before the shell bridge. This partial is included in the application, dashboard, devise, and native_handoff layouts.

The server-owned overflow menu

_settings_actions.html.erb (rendered only when native) is a hidden, server-owned overflow menu. It is how logout stays server-driven: a menu controller, an overflow-menu button, and a Turbo DELETE form to destroy_user_session_path whose hidden "Sign out" submit is the menu's item target. The native side presents the affordance; the web page stays the source of truth for what the action does.

Toast vs HTML flash

The toast bridge and the HTML flash fallback are mutually exclusive — 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/_settings_actions.html.erb
  • app/views/devise/shared/_error_messages.html.erb, _form_input.html.erb
  • app/javascript/controllers/railsfast/native/{toast,shell,menu,overflow_menu}_controller.js

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. Today /native/me is GET-only and RailsFast Base ships no skip_forgery_protection anywhere — so this is a contract for the future, not something already wired up. When you add a native write endpoint, pair it with an explicit CSRF policy. The blessed pattern is skip_forgery_protection scoped to JSON for authenticated, native-only endpoints. 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 }).

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.

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), matching /(Turbo|Hotwire) Native/ in the user agent. If you bump turbo-rails or change the UA string, native detection silently shifts.

RailsFast layers the UA prefix so Rails can detect three levels:

{AppName} Android; RailsFast Native Android; Hotwire Native Android; Turbo Native Android; bridge-components: [...]
  • hotwire_native_app? — any Hotwire app (gem-provided).
  • railsfast_native_app? — any RailsFast native app (the RailsFast Native {Platform} segment).
  • a specific app check — your own {AppName} prefix.

The {AppName} prefix is app-specific; the layered pattern is template-owned.

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

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.