DOCS LLMs

Customization

Once your RailsFast Base app and native shells are running, almost everything you'll want to change falls into a few predictable buckets. This page is the practical "I want to change X — where does it go?" guide across all three repos.

Read Native Overview first if you haven't. This page assumes you already hold the mental model: Rails is the product brain, the native repos are thin shells, and the shell states are auth / onboarding / tabs.

First Question: Where Does This Change Belong?

Before you touch any file, ask one question. It is the spine of this entire page:

Is this a product rule, or a native container detail?

A product rule is something about what your app means — and it should be remotely changeable without shipping a new mobile binary. It belongs in RailsFast Base.

  • which tab owns a route family
  • whether onboarding is finished
  • where users land after sign-in
  • whether pricing is visible in native
  • whether a route presents modally, as an auth sheet, or clears the stack
  • the title text and route ownership of a tab

A native container detail is something about how that meaning is presented on a specific platform. It belongs in RailsFast Android or RailsFast iOS.

  • tab icons and native fragment / view-controller classes
  • Android sheet sizing and activity transitions
  • iOS navigation bar appearance and Liquid Glass behavior
  • native toolbar behavior and tab labels
  • UIKit, SwiftUI, or Compose-only presentation polish
TIP

When a change touches both — like adding a tab, where Rails owns the route family and each platform owns the native rendering — it is a product rule with native plumbing. Do the Rails half first (it's the source of truth), then make each binary able to render it. The order matters: the server contract is what both shells reconcile against.

That single question saves you a lot of architecture drift. The rest of this page is just applying it to the changes you'll actually make.

Rename And Rebrand The App

This is almost always the first thing you'll do, and the two platforms have different tooling.

Android: bin/rename

RailsFast Android ships a bin/rename script that moves the Kotlin package tree and rewrites references for you. Run it from the Android repo:

# In railsfast-android
bin/rename \
  --package com.example.myapp \
  --app-name "My App" \
  --base-url https://app.example.com \
  --commit

What it does:

  • upserts railsfastNamespace, railsfastApplicationId, railsfastApplicationName, and railsfastDefaultBaseUrl in gradle.properties
  • physically moves the Kotlin package tree under app/src/{main,test,androidTest}/java
  • rewrites \b<old.package>\b references across every .kt and .java file
  • optionally updates native.android.package_name in a railsfast.yml you pass with --railsfast-config
  • optionally commits the result with --commit
TIP

Run it interactively without flags and it will prompt you for each value, defaulting to the current gradle.properties settings. Add --yes to skip the confirmation prompt in scripts.

After it finishes, recompile to confirm the package move worked:

./gradlew :app:compileDebugKotlin

iOS: project.yml + XcodeGen

iOS has no rename script because identity is driven by project.yml and the Xcode project is generated from it. Edit the build settings and Info.plist values:

  • RAILSFAST_BASE_URL — your Rails host (defaults to http://localhost:3000)
  • RAILSFAST_ASSOCIATED_DOMAIN — your Universal Links domain (defaults to example.com)
  • the bundle ID (com.railsfast.ios by default)
  • the app display name

Then regenerate the project:

# In railsfast-ios
xcodegen generate

App identity is not the same as visual branding. After the rename, follow App Icons and Launch Screens to generate the iOS 1024px icon, Android adaptive/legacy icon set, platform launch assets, and clean-install verification. Keep the canonical mark, wordmark, and brand color shared, but respect each operating system's different masking and splash layout rules.

NOTE

The app name flows into the user agent automatically. Both shells build a layered prefix — {AppName} Android; RailsFast Native Android; and {AppName} iOS; RailsFast Native iOS; — and Hotwire appends the framework identity and bridge-component list after that. The app prefix is yours; the layered pattern is template-owned. Don't hand-edit the RailsFast Native {Platform} segment, and never duplicate the Hotwire Native ... strings the framework adds. Rails relies on that exact layering to tell apart "any Hotwire app" from "any RailsFast native app" from "this specific app."

Common Changes

Change The Native Welcome Screen

This is a RailsFast Base change. The welcome screen is plain web HTML shared by both platforms:

  • app/views/native/auth/show.html.erb

Use it for branding, welcome copy, CTA hierarchy, and first-run visual design. Keep it shared between Android and iOS unless you have a strong reason not to — one HTML tree, two shells, is the whole point.

Change When Onboarding Ends

This is a RailsFast Base change:

  • app/models/user.rb — the User#native_onboarding? predicate

The default returns !belongs_to_any_organization?, so a user with no organization is still in setup. It's intentionally small: the RailsFast default, not a universal truth. Evolve it for your app's setup flow.

If your onboarding URLs change too, review the path configuration so the shell rules stay aligned:

  • app/controllers/native/configurations_controller.rb

Change Where Users Land After Authentication

This is a RailsFast Base change:

  • app/controllers/application_controller.rb — native_signed_in_entry_path_for

This is the post-auth handoff destination — the data-bridge-location the native shell reads to decide where to land. By default, onboarding users go to their org-creation path and everyone else lands on the dashboard.

Move A Page Into A Different Tab

This is usually a RailsFast Base-only change:

  • app/controllers/native/configurations_controller.rb — update the route_patterns for the relevant tab in settings.shell

Tabs are owned by route families (regexes matched against the URL path), not by their start URL alone. If the destination tab already exists on both binaries, you do not need a native code change — the server controls route ownership.

TIP

Query strings never change tab ownership. /settings?return_to=/dashboard still belongs to the settings tab, because tab matching is path-only on both platforms (iOS applies route_patterns to URL.path; Android to URI.path). Write your route_patterns against the path — never encode query strings into them.

Query strings and fragments can still be meaningful to the page visit. A root URL like /settings?return_to=/dashboard is still a Settings tab root for ownership/reset purposes, but iOS treats it as a distinct visit during cold start so the query is not dropped. Keep start_path canonical (/settings) and let the native shell preserve the full incoming URL.

Change Native Title Fonts

This is a native-container change, not a Rails CSS change. Fonts loaded by the Rails HTML affect web content inside the WebView; they do not affect:

  • iOS UINavigationBar titles
  • Android Toolbar / MaterialToolbar titles
  • native tab labels
  • native SwiftUI or Compose screens

Native chrome renders before the first web page loads, offline from the Rails asset pipeline, and across cached Hotwire Native sessions — so when it needs a custom font, that font lives in the binary. The two platforms are deliberately asymmetric here:

  • RailsFast iOS always shows a native UIKit navigation bar, so the starter ships a real font hook (NAVIGATION_TITLE_FONT_NAME + UIAppFonts).
  • RailsFast Android renders its branded header in the web view, so most chrome is already styled by your Rails app. The starter ships no Android font hook; the few genuinely-native labels stay on the system font unless you opt into standard Android theming yourself.

Rails always owns the title text and the tab contract. The platforms only differ on how they paint it.

Add A New Tab

A tab is the clearest example of a product rule with native plumbing. It touches four places that must stay aligned on the shared bits. Do them in this order.

1. RailsFast Base (the source of truth)

Edit app/controllers/native/configurations_controller.rb and add the tab to settings.shell.tabs on both the iOS and Android generators. Each tab is { key, start_path, route_patterns, visible }. The settings.shell block is byte-identical across platforms — only the per-platform rules[] differ — so keep default_tab, the tab keys, and each tab's start_path / route_patterns the same in both. start_path should be the clean canonical path for the tab root, without query strings or fragments.

2. RailsFast Android

  • MainTabs.kt — add the tab to supportedTabs (key, default start path, host id, default route patterns)
  • activity_main.xml — add a FragmentContainerView navigator host for it

3. RailsFast iOS

  • RailsFast/Shell/Tabs.swift — add the tab to defaultTabs (key, title, SF Symbol name, start path, route patterns)
  • RailsFast/Resources/path-configuration.json — keep the bundled fallback's settings.shell matching the Rails contract

4. Keep the bundled fallbacks in sync

Each platform ships its own bundled path-configuration.json as a fallback for when the remote contract is stale or hasn't loaded yet. They are two separate files. Update both.

WARNING

The compile-time tab catalogs (Android MainTabs.kt, iOS Tabs.swift) and the two bundled path-configuration.json fallbacks must stay identical to the server's settings.shell on the shared parts — keys, start_path, and route_patterns. If the catalogs drift from the server, cross-tab routing silently sends users to the wrong tab and you'll chase a "navigation feels broken" ghost. Treat the server settings.shell as canonical and reconcile the rest against it. The native repos have JVM/XCTest assertions for this contract; keep them green.

NOTE

The whole reason a binary carries its own tab catalog is so the server 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. That's the payoff. On iOS, a known tab key reuses the native title/icon defaults; an unknown server-supplied key is still accepted at runtime without a code change, but only if Rails also supplies an iOS-safe title and a valid SF Symbol name (ios_system_image_name or system_image_name). You only edit Tabs.swift when you want a native default title/icon for a known key.

Change Presentation: Modals, Sheets, And Detents

How a route presents is a product rule, so it lives in Rails path configuration — but the grammar differs by platform, and you must not copy one platform's properties into the other.

  • iOS uses view_controller + modal_style (medium / large / full / page_sheet / form_sheet) + presentation.
  • Android uses uri / fallback_uri deeplinks (hotwire://fragment/...) and ignores modal_style entirely.

The shipped auth sheets are the canonical worked example. On iOS, signup and signin are two separate rules:

// app/controllers/native/configurations_controller.rb (iOS rules)
// /users/sign_up -> taller form, opens at the large detent
{ "patterns": ["^/users/sign_up$"], "properties": { "context": "modal", "modal_style": "large" } }
// /users/sign_in -> just email + password, half-height
{ "patterns": ["^/users/sign_in$"], "properties": { "context": "modal", "modal_style": "medium" } }

Signup uses large because its form is taller (email, password, confirmation, terms) and the medium detent forced users to swipe up before they could accept the terms. Signin stays medium.

On Android, those two collapse into a single auth-sheet rule, because Android's bottom sheet always opens expanded:

// app/controllers/native/configurations_controller.rb (Android rules)
{ "patterns": ["^/users/sign_up$", "^/users/sign_in$"],
  "properties": { "context": "modal", "uri": "hotwire://fragment/web/modal/auth-sheet" } }
IMPORTANT

When you change presentation, edit the rule in the matching per-platform generator in configurations_controller.rb, and keep each binary's bundled fallback in sync. Don't add modal_style to the Android rules — it's a no-op there. The generic /new$ and /edit$ paths already present as plain modals; add specific rules only when a route needs a specific detent or sheet.

Add A Fully Native Screen

Reach for this only when there's a strong, platform-specific reason: maps, camera-heavy flows, document/barcode scanning, OS-level integrations, or a heavily interactive native home screen. Most screens should stay web.

When you do:

  1. Keep the canonical Rails URL as the screen's identity. On iOS, map it with view_controller in path configuration; on Android, register a fragment destination. The reference implementation is the /native/me account screen on both platforms — copy its shape.
  2. Add the native UI in the native repo. SwiftUI on iOS, Compose on Android.
  3. For HTTP, reuse the shared Rails session — and gate any write behind the server-side CSRF contract below.

That third point is where people get hurt, so it gets its own section.

The CSRF Write Contract (read this before you POST)

Authentication for native HTTP works through the shared session cookie. Reads are the safe default; writes need a server-side CSRF policy before they'll succeed — regardless of whether the client exposes a write helper. iOS's NativeHttpClient is GET-only by design; Android's also ships postJson/patchJson, but those will 422 until you add the endpoint policy below. The gate is the server contract, not the client surface.

  • iOS (NativeHttpClient.swift) configures its URLSession with httpCookieStorage = .shared. Hotwire Native syncs WebView cookies into HTTPCookieStorage.shared after every page load (via Navigator.sessionDidFinishRequest), so native GET requests automatically carry the Rails session cookie. The shared cookie store is populated from the WebView's WKWebsiteDataStore by the framework — the load-bearing piece is that framework sync plus the URLSession using .shared, not your code reading the data store directly.
  • Android (NativeHttpClient.kt) must explicitly copy the Cookie header from CookieManager into each request, because OkHttp does not reuse the WebView's cookies on its own.
WARNING

Cookie sync solves authentication, not CSRF. A signed-in cookie lets a native GET read authenticated pages — but Rails still enforces CSRF on every unsafe method, and a native URLSession/OkHttp request does not carry Rails' X-CSRF-Token from the WebView DOM. A native POST/PUT/PATCH/DELETE without the server-side policy below will get a 422 and no obvious reason why — even though Android's NativeHttpClient exposes postJson/patchJson. iOS stays GET-only by design for the same reason; add the contract below before you wire a write on either platform.

The blessed pattern for native writes is an explicit, scoped CSRF exemption on an authenticated, native-only JSON endpoint:

# app/controllers/native/your_writes_controller.rb
class Native::YourWritesController < ApplicationController
  # Native requests are cookie-authenticated but cannot carry the DOM CSRF token.
  # Scope the exemption to JSON + authenticated native requests only — never blanket.
  skip_forgery_protection if: -> { hotwire_native_app? && request.format.json? }
  before_action :authenticate_user!

  def create
    # ... your write ...
  end
end

Pair the clients with this contract and your native writes will work. Skip it and they won't. There is no second auth system here — do not invent a parallel bearer-token scheme for native screens.

Add A Bridge Component

Bridge components are for progressive native enhancement: the screen stays mostly web, but one piece becomes native — a native menu, a confirmation toast, an overflow affordance, a shell-state signal. They are not the answer for root tabs; tabs are native shell infrastructure.

IMPORTANT

Four bridge components ship today and are registered on both platforms: native-shell, toast, menu, and overflow-menu. Those four are your copy-from templates. Anything else (a share sheet, a review prompt, a rating dialog) is something you would build — there is no shipped catalog beyond these four.

A bridge component is a three-sided contract, and all three sides must agree on the event names, the JSON payload schema, and the reply schema.

  1. The web side — a Stimulus controller in the railsfast--native--* namespace, in RailsFast Base, rendered into the HTML (gated by component support, see below).
  2. The iOS side — a BridgeComponent subclass in RailsFast/Bridge/, registered in AppDelegate.configureHotwire().
  3. The Android side — a BridgeComponent subclass under bridge/, registered in RailsFastApplication.configureHotwire().

The existing four are the contracts to mirror exactly:

  • toast — web sends show with { message, severity } (severity is success | error | warning | info).
  • menu — web sends display with { title, items: [{ title, index }] }; native replies { selectedIndex }.
  • overflow-menu — web sends connect with { label }; native replies connect when tapped.
  • native-shell — web sends connect with { state, handoff, location? }.

The one rule that governs all bridge markup:

Gate bridge output by component support, not by platform.

Hotwire appends a bridge-components: [...] segment to the user agent once each native app registers its components. Rails reads that list and only renders a bridge tag if the component is advertised. So you never branch on iOS vs Android — you check whether the component is supported, and if both platforms register it, Rails sends both the identical payload. See the bridge contracts section for the server-side helper that does this.

CAUTION

A bridge component is only "added" when all three sides ship together. A web Stimulus controller with no native counterpart does nothing; a native component with no web emitter never fires. Build and register all three, with identical event/payload/reply schemas, or the component silently no-ops.

Billing And Pricing

WARNING

Be careful here. By default RailsFast treats native as a companion app: the web pricing flow is kept out of the native app. /pricing, /subscribe, and /billing redirect back into the app shell for native requests, upgrade CTAs that depend on web pricing stay hidden, and pending web checkout intent is discarded.

Do not casually bring the web pricing page back into native. App Store and Play Store both have rules about charging outside their billing systems, and "native pricing accidentally half-enabled" is a way to get rejected. Turn it on only with a deliberate App Store / Play billing strategy. The companion-app redirects live in app/controllers/application_controller.rb.

Know What's Template Machinery vs Your Product Code

Some of what looks customizable is actually RailsFast Native's load-bearing plumbing. Editing it is editing the template, not the framework, and Hotwire's upstream docs won't describe it — so tread carefully and keep it generic.

These are template primitives. They stay in RailsFast and shouldn't be forked casually:

  • the native-shell state machine (auth / onboarding / tabs)
  • the auth handoff flow (/native/entry + /native/handoff)
  • session persistence (Devise rememberable, auto-enabled for native)
  • path-config generation (configurations_controller) and the CSRF policy
  • NativeHttpClient (the cookie-forwarding infrastructure on both platforms)
  • cross-tab routing
  • the railsfast--native--* Stimulus namespace
NOTE

The two-shell split (an auth/onboarding shell plus a tab shell) and the three-state machine are a RailsFast addition, not stock Hotwire Native — vanilla Hotwire Native gives you a single tab-bar root with no auth shell. So if you customize shell behavior, you're editing template-owned machinery; the official Hotwire docs won't cover it. Change SceneDelegate.swift / NativeShellComponent.swift (iOS) or AuthActivity.kt / MainActivity.kt (Android) only when the shell architecture itself genuinely needs to change.

Everything else — your screens, your models, your routes, your product copy — is yours. The line is simple:

If a thing needs a specific Rails controller, route, or helper to work, it's template machinery. If it's a screen or a feature, it's your product code.

Feeding Improvements Back Upstream (The Testbed Pattern)

RailsFast Native is built and proven inside a real app before it lands in the templates. The intended loop:

  1. Incubate a feature or bridge component inside a real product app and the templates.
  2. Prove it on both Android and iOS — a contract that only survived one platform isn't generic yet.
  3. Port the generic part upstream into railsfast-base, railsfast-android, and railsfast-ios, keeping the product-specific part in your own app.

When you build something genuinely reusable, that's the path back into the templates. But be honest about the split:

CAUTION

Keep your product's domain code out of the template. Marketplace tabs, GPS/maps, scanning flows, your specific four-tab layout, your brand colors and logos, your language copy, your package names and signing config, and your domain-specific screens are app code — they should never be documented or ported as part of the template. The template stays English-first, three tabs (Home / Account / Settings), brand-neutral, and generic. Port the primitive, not your product.

NOTE

There is also a planned free OSS bridge-component library (Toast, Menu, Overflow Menu, Diagnostics). It is a future project, not a current dependency — there is nothing to pin or add to your Gradle/SPM setup today. Build bridge components inside your app and the templates for now.

Keeping Things Maintainable

The fastest way to make the native setup painful is to let product rules leak into the native repos, and native-only hacks leak back into Rails. Preserve the split:

  • Rails owns the product contract.
  • Android owns Android presentation.
  • iOS owns iOS presentation.

When you update later, treat the three repos separately — update railsfast-base, railsfast-android, and railsfast-ios each on their own, and reconcile your downstream customizations in each. This is the same discipline as the rest of RailsFast: keep upstream-owned code organized, keep your customizations intentional, and don't edit files in-place without knowing which side owns them. If you customize RailsFast-owned native files heavily, apply the update discipline in Updating RailsFast.

And remember: the bundled native path configuration is a fallback, not a second source of truth. If you change the remote Rails contract, keep the bundled Android and iOS fallbacks in sync.