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
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, andrailsfastDefaultBaseUrlingradle.properties - physically moves the Kotlin package tree under
app/src/{main,test,androidTest}/java - rewrites
\b<old.package>\breferences across every.ktand.javafile - optionally updates
native.android.package_namein arailsfast.ymlyou pass with--railsfast-config - optionally commits the result with
--commit
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 tohttp://localhost:3000)RAILSFAST_ASSOCIATED_DOMAIN— your Universal Links domain (defaults toexample.com)- the bundle ID (
com.railsfast.iosby 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.
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— theUser#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 theroute_patternsfor the relevant tab insettings.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.
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
UINavigationBartitles - Android
Toolbar/MaterialToolbartitles - 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 tosupportedTabs(key, default start path, host id, default route patterns)activity_main.xml— add aFragmentContainerViewnavigator host for it
3. RailsFast iOS
RailsFast/Shell/Tabs.swift— add the tab todefaultTabs(key, title, SF Symbol name, start path, route patterns)RailsFast/Resources/path-configuration.json— keep the bundled fallback'ssettings.shellmatching 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.
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.
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_urideeplinks (hotwire://fragment/...) and ignoresmodal_styleentirely.
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" } }
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:
- Keep the canonical Rails URL as the screen's identity. On iOS, map it with
view_controllerin path configuration; on Android, register a fragment destination. The reference implementation is the/native/meaccount screen on both platforms — copy its shape. - Add the native UI in the native repo. SwiftUI on iOS, Compose on Android.
- 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 itsURLSessionwithhttpCookieStorage = .shared. Hotwire Native syncs WebView cookies intoHTTPCookieStorage.sharedafter every page load (viaNavigator.sessionDidFinishRequest), so native GET requests automatically carry the Rails session cookie. The shared cookie store is populated from the WebView'sWKWebsiteDataStoreby the framework — the load-bearing piece is that framework sync plus theURLSessionusing.shared, not your code reading the data store directly. - Android (
NativeHttpClient.kt) must explicitly copy theCookieheader fromCookieManagerinto each request, because OkHttp does not reuse the WebView's cookies on its own.
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.
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.
- The web side — a Stimulus controller in the
railsfast--native--*namespace, in RailsFast Base, rendered into the HTML (gated by component support, see below). - The iOS side — a
BridgeComponentsubclass inRailsFast/Bridge/, registered inAppDelegate.configureHotwire(). - The Android side — a
BridgeComponentsubclass underbridge/, registered inRailsFastApplication.configureHotwire().
The existing four are the contracts to mirror exactly:
- toast — web sends
showwith{ message, severity }(severity issuccess|error|warning|info). - menu — web sends
displaywith{ title, items: [{ title, index }] }; native replies{ selectedIndex }. - overflow-menu — web sends
connectwith{ label }; native repliesconnectwhen tapped. - native-shell — web sends
connectwith{ 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.
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
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-shellstate 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
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:
- Incubate a feature or bridge component inside a real product app and the templates.
- Prove it on both Android and iOS — a contract that only survived one platform isn't generic yet.
- Port the generic part upstream into
railsfast-base,railsfast-android, andrailsfast-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:
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.
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.