# RailsFast Documentation > Complete documentation for RailsFast - The AI coding template ## Documentation - [🚀 Welcome! - RailsFast Docs](/docs/) - [Prerequisites](/docs/getting-started-1-prerequisites/) - [Quickstart (15 Minutes)](/docs/getting-started-2-quickstart/) - [Rebrand & customize](/docs/getting-started-3-customization/) - [Configure credentials](/docs/getting-started-4-configuration/) - [Developing your app](/docs/getting-started-5-develop/) - [For AI Agents](/docs/getting-started-6-ai-agents/) - [RailsFast Native Quickstart](/docs/native-0-quickstart/) - [RailsFast Native Overview](/docs/native-1-overview/) - [RailsFast Native - Server](/docs/native-2-railsfast-base/) - [RailsFast Native - Android](/docs/native-3-android/) - [RailsFast Native - iOS](/docs/native-4-ios/) - [Customization](/docs/native-5-customization/) - [App Icons and Launch Screens](/docs/native-6-icons-and-launch-screens/) - [RailsFast Native - Notifications](/docs/native-7-notifications/) - [RailsFast Native - Safe areas & fixed docks](/docs/native-8-safe-area-and-docks/) - [RailsFast Native - Dark mode](/docs/native-9-dark-mode/) - [RailsFast Native - Web Forms & The Keyboard](/docs/native-10-web-forms-and-the-keyboard/) - [RailsFast Native - Offline mode](/docs/native-11-offline-mode/) - [Stripe, payments & plans](/docs/features-0-payments/) - [User authentication & login](/docs/features-1-auth/) - [Your new admin tools](/docs/features-2-admin/) - [Sending transactional emails](/docs/features-3-emails/) - [Background jobs & scheduled cron jobs](/docs/features-4-background-jobs/) - [Uptime and monitoring](/docs/features-5-monitoring/) - [SEO & Sitemaps](/docs/features-6-seo/) - [Analytics](/docs/features-7-analytics/) - [Encrypting sensitive data](/docs/features-8-encryption/) - [Caching](/docs/features-9-caching/) - [Sessions & devices](/docs/features-10-sessions-devices/) - [User-to-user chats](/docs/features-11-chats/) - [Attachments and cloud storage (S3-compatible)](/docs/features-12-attachments/) - [Abuse protection](/docs/features-13-abuse-protection/) - [Usage credits](/docs/features-14-usage-credits/) - [Blog and content (CMS)](/docs/features-15-cms-content-blog/) - [Organizations & Teams](/docs/features-16-organizations/) - [UI Overview](/docs/ui-components-1-ui-overview/) - [Social Proof](/docs/ui-components-2-social-proof/) - [Testimonials](/docs/ui-components-3-testimonials/) - [FAQ System](/docs/ui-components-4-faq-system/) - [Features](/docs/ui-components-5-features/) - [Call to action](/docs/ui-components-6-cta/) - [Pricing page](/docs/ui-components-7-pricing/) - [Other components](/docs/ui-components-8-other/) - [Customizing components](/docs/ui-components-9-customizing/) - [AI image generation](/docs/ai-2-image-generation/) - [LLMs and AI chats](/docs/ai-3-llm-chat/) - [AI, vector databases, and RAG](/docs/ai-4-vectors-and-rag/) - [How to get a 10/10 deliverability score for your emails](/docs/guides-email-deliverability/) - [Kamal - Configure a container registry](/docs/guides-kamal-container-registry/) - [Kamal - Configure a remote builder](/docs/guides-kamal-remote-builder/) - [Migrating Existing Project](/docs/guides-migrating-existing-project/) - [Deploy multiple Rails apps to the same server](/docs/guides-multiple-apps/) - [Restoring your database backups — the runbook](/docs/guides-restoring-backups/) - [The server setup script — hardening & maintenance](/docs/guides-server-setup-script/) - [Stripe for serial founders — one Organization, one account per project](/docs/guides-stripe-organizations/) - [Updating RailsFast](/docs/guides-updating/) - [Beyond credits: wallets for games, telecom, and marketplaces](/docs/guides-wallets-beyond-credits/) --- ## Full Content ## 🚀 Welcome! - RailsFast Docs Welcome to RailsFast! You're only a few minutes away from putting your first RailsFast app in production! But first of all: thanks for purchasing! You've made a great choice. This single decision could very well be the start of your own software business; or it could be the lever to finally take that idea you've been wanting to build into reality. > [!TIP] > 👉 You can go straight ahead and **[[getting-started/1-prerequisites|start building now]]**, or you can read below why I made this project. > [!NOTE] > 🤖 **Are you an AI agent** (or pointing one at these docs)? RailsFast is built to be driven by agents end to end — start with **[[getting-started/6-ai-agents|For AI Agents]]**: the guarantees you can rely on, the day-zero playbook that takes *"build MyProject on myproject.com"* to a live site, and the handful of steps that genuinely need a human. The entire docs in one LLM-shaped file: [railsfast.com/docs/llms.txt](https://railsfast.com/docs/llms.txt). --- ---

RailsFast Logo

## Why RailsFast RailsFast is essentially a template, but it's also a bit more than a template – as you'll soon discover. It's a way of building and running software startups in a very solid, secure, and streamlined way. A way that gives you back total freedom, ownership, and control over your software business so you can be fast and independent. I ([Javi](https://x.com/rameerez), the maker of RailsFast, the one writing all these docs) personally think this is the best way one could build and run SaaS and web apps, and that's why I myself use RailsFast for every new app I make. Building a software startup means making millions of tiny decisions, and the promise when buying a template is that most of those decisions are made for you, giving that time back to you and freeing you up to build the actual product instead. That's a great responsibility, and the template is only as powerful as the quality of the decisions it incorporates. If the person building the template makes the wrong decisions, those bad decisions cascade downstream and multiply for every single user of the template. It's unfortunately not uncommon to see fairly inexperienced developers making templates that are later found to contain massive flaws that could have been easy to prevent, causing a lot of headaches for a lot of people. It's also unfortunately not uncommon to see templates made by seasoned engineers that despite their technical ability have never built a run any software business other than the template itself – which also reflects in the quality of the decisions, because all of a sudden the entire thing is a maze of technical complexity that cripples any aspiring entrepreneur just wanting to put something fast out there. As for myself, I've been both developing software and running startups for the past 10+ years. I'm familiar with the technical stuff _and_ the business pains. I put my first website on the internet in 2006, and since then I've created and scaled Rails startups to over 4+ million users a month, I've built Ruby gems downloaded 100k+ times, I've perfected Rails SEO to get Google to send my websites millions of clicks in organic traffic, I've been viral with Rails-based AI projects featured on media like The Washington Post, Fast Company, TNW; and I've made dozens of other projects, not only Rails-based. I've launched apps using everything from TypeScript and JavaScript, to PHP, to React / Next, to Python. Throughout the years I've tried and tested many technologies, libraries and frameworks, and I wouldn't use anything other than Ruby on Rails to build and run a web-based software businesses – that's why Ruby on Rails is the technology of choice for this template. You only have to look at the list of startups that are made with Rails (GitHub, Shopify, Coinbase...) to realize you'll be in great company. As for security, scalability, performance, auth, and those other myriads of little yet critical decisions: RailsFast incorporates everything I've learned in my 10+ years building and running startups. I had to scale startups to millions of users, but I've also been hacked, I've been DDoS'd, I've been penalized by Google, botnets from Kazakhstan have tried to jam my API endpoints... I've seen a fair deal of things. I'm by no means perfect, but every one of those things left scar tissue that I've now coded into RailsFast, to prevent some of those problems (or at least help a little bit) and make your journey easier. I started making RailsFast after selling PromptHero, my biggest Rails-based startup to date. I launched it in 2022 and it grew very very fast, so I had to develop a lot of things _very quickly_. I was surprised I had to develop from scratch things as basic and universal as a user credits system or plan limits enforcement; and since the business was growing so fast I couldn't spend much time engineering, so the resulting quality of those systems was not the best. That's why when after selling the business I started creating new projects, and I came across the exact same problems, I couldn't believe I had to re-implement these things all over again. But since this time over I could spend a bit more time engineering, I set out to do things right. So for an entire year I broke down into Ruby gems all the little parts from PromptHero that I needed to reuse in my new apps. The result were 15+ Ruby gems I could stack up like LEGO pieces to build new apps very fast. It worked, and now thousands of developers around the world use them. But I was still spending a lot of time wiring everything together for every new project. I was missing something that would bring all those gems into a cohesive template that _just worked_, with everything already wired up and preconfigured out of the box, so I could just clone it and make new projects _like churros_. That was the starting point for RailsFast. Then of course the AI-assisted coding revolution was ongoing, and I saw a lot of my peers struggling with _vibe coding_. At first I didn't get it, because it worked so well for me. Then I realized a few main differences between their approach and mine. The first thing was the choice of technology: Rails is simply superior for vibe coding compared to, say, JavaScript. That alone would explain the difference in results, but on top of it they were also asking the AI to create everything from scratch for every new project. Without a template that leverages known truths, learnings, and well-tested libraries, what you get is a blob of hallucinated spaghetti code: the perfect recipe for disaster. And lastly, I realized the way I structured my Rails projects was fairly optimized for AI. If you write good context (rules, prompts, etc.) and organize things logically, the AI agents understand quickly what to do in each part of the codebase, which makes things significantly easier and more accurate. So I decided to make RailsFast not just good for manually cloning and building a startup, but designed explicitly for AI from the beginning. Other than Rails, AI, and code itself, I've also arrived at a pretty stable set of external tools I now use in literally every project. I think those are the best tools one could use, and therefore RailsFast is designed for them. For example, RailsFast uses Stripe for payment processing, Cloudflare for caching and DDoS prevention, Hetzner for servers to deploy your app to, and R2/S3 for storage. I believe this stack is extremely well battle-tested and maximizes your freedom & ownership as a software business owner. It also happens to be dirt cheap (or straight up free), allowing you to launch and run many app ideas for close to nothing. You can change everything in RailsFast, of course. At the end of the day it's just code you can edit. But if you change some of these essential pieces, it will be at the cost of adding a lot of complexity. As a rule of thumb: RailsFast, as you bought it, is guaranteed to work. Mess with the recipe, and it may no longer work. I believe it's opinionated in a good way: it makes a lot of good decisions for you, so you can fully focus on building your product; and it provides strong guardrails for the AI so it gets things done without getting lost. The other thing I believe RailsFast excels at is forcing you to **ship early**, and **ship often**. You're going to be pushing things to production since the very first minute of the onboarding. This is because the single biggest and most common mistake for entrepreneurs like us is launching too late. We tend to spend too much time developing locally without ever showing anyone, and it's often the case that our projects never see the light of day. Who knows why: maybe we think they're never good enough to be made public, or maybe by the time we're happy the project is already too complex to be put in production easily. The result in any case is that we keep postponing the moment of publishing, and by the time we realize, months have already passed. Since this is probably the #1 problem for software entrepreneurs like us, it's exactly what we're going to address first thing in RailsFast. We're going to completely reverse the order things are usually done, and so we're going to launch our app first in the quickstart. We'll begin our journey by publishing our project, giving us a shareable URL straight from the beginning, and forcing us to ship, and to do so iteratively. It'll help us develop a bias towards action. It will only be after your project is live and online for everyone to see that we'll start developing it. I deeply believe having an unfinished website already live will give you motivation to fix it and build, and most importantly: to share your project from the start. With that being said, I really hope you put RailsFast to good use! I myself am going to be building many things based off it, so expect the template to get updated and improved as I launch new stuff. I truly believe you're at an advantage here over any other JavaScript-based templates or people rawdogging vibe coding, and I think you'll realize it too very soon. If you build something cool with RailsFast please [let me know on X](https://x.com/rameerez) so I can see it and add it to the list of projects in the RailsFast website! 👉 Your journey [[getting-started/1-prerequisites|starts here]]! Good luck! --- --- ## Prerequisites Let's set strong foundations first so you can fly through the upcoming 15-minute quickstart. ## Before you start It makes things easier if before starting you have already decided: - [ ] A **name** for your project - [ ] A **domain name** (like `myproject.com`) to deploy your project to > [!TIP] > You can also use a subdomain (like `myapp.example.com`) instead of a domain, but in any case you need to have either purchased and ready to use if you want to deploy your app to production. I recommend buying your domains from [Cloudflare](https://www.cloudflare.com/products/registrar/) (they sell domains at cost) You can always rebrand and change your project name and domain later, but renaming everything everywhere is always a pain, so I strongly recommend setting it right from the start. ## Technical requirements You should have the following things installed and configured in your development computer: - [ ] `git` ([guide](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git)) - [ ] Ruby 4.0+ and Rails 8.1+ ([guide](https://guides.rubyonrails.org/install_ruby_on_rails.html)) — RailsFast pins the exact Ruby in `.ruby-version` (currently 4.0.5), so `rbenv install` / `mise install` inside the project directory gets you the right one - [ ] Docker Desktop ([macOS](https://docs.docker.com/desktop/setup/install/mac-install/), [Windows](https://docs.docker.com/desktop/setup/install/windows-install/)) - [ ] PostgreSQL 15+ ([macOS](https://postgresapp.com/), [Windows](https://www.postgresql.org/download/windows/)) - [ ] `hcloud`, the official [Hetzner Cloud CLI](https://github.com/hetznercloud/cli) (`brew install hcloud`) — optional if you plan to click around the Hetzner web console yourself, but it's how the terminal/agent lane creates servers, and how an AI agent will do the whole server setup for you. (If you're driving RailsFast with an agent, don't sweat this one: agents install it themselves.) ## External services For your RailsFast app to run in production (live for everyone to see and use), you will need accounts in: - [ ] [GitHub](https://github.com) (for storing your code) - [ ] [Hetzner](https://hetzner.cloud/?ref=dSlYpM3ogSsu) (for servers to deploy to) [Get $20 in credits with [this link](https://hetzner.cloud/?ref=dSlYpM3ogSsu)] - [ ] [Cloudflare](https://www.cloudflare.com) (for caching and DDoS protection) - [ ] [Stripe](https://stripe.com) (for payment processing) - [ ] [AWS](https://signin.aws.amazon.com/signup) (for storage and mailing) These are the RailsFast recommendations. These are not hard requirements, but we'll move forward assuming you have accounts in these services. You may be able to develop your RailsFast app locally without any of these, but they'll be needed for putting your app in production. You may swap some of these external services for others you prefer (for example, using DigitalOcean instead of Hetzner, or using Mailgun instead of AWS SES), but these are not the official RailsFast recommendations and therefore RailsFast may not support them. It's strongly encouraged you stick to these recommendations. > [!TIP] > If you've skipped it, I recommend going back to the [[index|docs home page]] and reading the motivation behind RailsFast. It's not mandatory, but it's a good read and I believe it will help you understand why we'll be doing the things we'll be doing. ## Development tools To develop / vibe code your RailsFast app, I recommend: - [ ] [Cursor](https://cursor.com) (and/or [Claude Code](https://code.claude.com/docs/en/overview)) - [ ] [Context7](https://context7.com) MCP (get your free API key [here](https://context7.com/docs/api-guide)) You can also just use VS Code, `nvim` or any other code editor if you like it more: development tools are very developer-specific, and therefore these are not hard requirements. RailsFast should work fine with any code editor you choose, but you'll only get the best AI-assisted experience if you stick to Cursor/Claude Code. > [!NOTE] > Throughout this first few "Getting Started" pages, I'll try to get you up and running from zero, explaining things in simple terms without making many assumptions about how much you may already know about Rails or web development in general. If you're an experienced developer, you may feel I'm overexplaining at times: please remember it's just to make onboarding easier for people that may not be as experienced. After the Getting Started section, the docs will get more technical. If, on the other hand, you're starting from scratch and this is the first time you work with an entire web project by yourself, you may find those extra explanations helpful. If you still have questions, no matter how basic, paste the docs URL into Grok or ChatGPT and ask away! P.S.: I'm writing **everything** in this "Getting Started" manually, without AI -- I've put a lot of effort and care into it so it's not AI slop! All set? Jump to the 15 min quickstart! --- ## Quickstart (15 Minutes) The main goal of RailsFast is getting you to put _something_ online as fast as humanly possible. I've worked really hard to make something that takes, literally, only 15 minutes to production. While it won't be your finished product just yet, it will be something that at least _is out there_ and people can visit. Something that proves your entire RailsFast workflow from development to production works fine. Something to build upon so you can start charging money as soon as possible. > [!IMPORTANT] > This guide assumes you've already installed everything described in the [[getting-started/1-prerequisites|Prerequisites]] section. Please make sure everything is installed and configured before continuing.
You can visit the demo project deployed in the video above live at [demo.railsfast.com](https://demo.railsfast.com). ## Kick off your RailsFast project Start by accessing the main RailsFast repository [`railsfast/railsfast-base`](https://github.com/railsfast/railsfast-base), to which you should have gotten access upon purchasing, and clone it: ```bash git clone --origin railsfast https://github.com/railsfast/railsfast-base.git myapp ``` > [!TIP] > Replace `myapp` in the command above with the name of your app. Open the `myapp` folder that has been created in Cursor. This is your very own RailsFast project now! ## Push your project to GitHub Right now you already have a working codebase, so let's save it to GitHub. Open up your GitHub account and create a **PRIVATE**, empty repository (no template, no README, no .gitignore, no license). > [!WARNING] > Make sure the repository you create is __private__! RailsFast doesn't allow public repositories. Point your local project’s origin to your new repo: ```bash git remote add origin https://github.com/YOUR-USERNAME/myapp.git ``` And push the code: ```bash git branch -M main git push -u origin main ``` ## Run the project setup script Now open up your project in Cursor and run this in the terminal: ```bash bin/railsfast init ``` This will start the interactive RailsFast setup script. First, it will ask you to name your project. Then, it'll generate secure encryption files, build your encrypted credentials (with a fresh `secret_key_base` and [[features/8-encryption|Active Record encryption keys]]), set up the right deployment config, and essentially get your project ready for deployment. > [!TIP] > Scripting the setup (or letting an AI agent run it)? The whole thing works non-interactively too: `bin/railsfast init --yes --name "My App"` skips every prompt. The name is kept exactly as you type it for all human-facing surfaces, while the derived module and database names are computed from it. At the end, if everything went right, you should see a success message telling you the project is now ready to deploy. ## Deploy your project We're now ready to deploy the RailsFast project to production! We only need a server to deploy to. > [!INFO] > A server may also be called a VPS (Virtual Private Server). ### Spin up a server (VPS) Go to [Hetzner](https://console.hetzner.com) and log in to your dashboard. Create a project if you don't have one (a "project" in Hetzner is just a named folder under which resources like servers are grouped). Then, inside the Hetzner project, click on the `[ Create Resource ]` button, and then click on the `[ Servers ]` button. #### Select a server type and size For a low-traffic app (an app that's just starting, or a hobby project) pretty much any size will do. You can even fit multiple RailsFast apps in the same server to save costs. For now, let's choose a good cheap-yet-powerful server with 2vCPUs and 2GB RAM: - For **type**, choose: `Regular Performance` under `Shared Resources` (`x86`) - For **size**, any 2 vCPU / 2–4 GB model does great. At the time of writing that's `cx23` in Europe (~€5.49/mo) and `cpx11` in the US — but exact models, prices and stock genuinely shift week to week, so treat these as examples and check what's available where you deploy (next note). A server like this will be more than enough and typically costs around €5/mo. > [!NOTE] > Hetzner's stock and pricing shift over time and vary per location — some days the cheapest machines are only in stock in one or two locations, and the same model can cost different amounts in different regions. Don't take the model names above as gospel: check what's actually available in your location before creating the server (any 2vCPU / 2GB+ shared x86 machine works great). In the terminal: `hcloud server-type list` shows every model with its specs and the locations where it's offered, and `hcloud server-type describe cx23` shows, for each location, whether it's actually in stock (`Available: yes/no`) and its monthly price. > > **Agents, two specifics**: parse with `-o json` (the human-readable output prints prices like `€ 5.4900000000…`, which naive text-splitting mangles into `0.00`), and here's the cheapest-in-stock sweep in one go: > ```bash > for t in cx23 cx33 cpx11 cpx21 cpx22; do > hcloud server-type describe $t -o json | jq -r '. as $st | $st.locations[] > | select(.available) | .name as $l > | ($st.prices[] | select(.location==$l)) as $p > | [$st.name, $l, $p.price_monthly.gross] | @tsv' > done | sort -t$'\t' -k3 -n | head -5 > ``` There are other cost-optimized options for about $3/mo you can experiment with, but their location availability may vary. You can also choose a server a bit more expensive like `CPX31` or `CPX32` (4vCPUs and 8GB RAM) for ~$20/mo if you expect your app to have more traffic / load, if you expect to fit multiple apps in the same server, or if you'd just like to have some extra room to grow from the start. #### Select a server location Make sure to choose a location physically close to your customers (if your users are mainly in America, spin up the server in the US; if most of them are in Europe, choose one of the European locations, etc.) Keep the rest of the config as default (Ubuntu OS image, public IPv4 and IPv6 addresses, etc.) — the RailsFast setup script supports both **Ubuntu 24.04 LTS and 26.04 LTS**, so whichever of those Hetzner offers as default works. Scroll down until the SSH section. #### Create or choose a SSH key to access your server To access your server and deploy code to it you'll need an SSH key. Create one if you don't have one: ```bash ssh-keygen -t ed25519 -f ~/.ssh/my_hetzner_ssh_key ``` > [!TIP] > Replace `my_hetzner_ssh_key` with the name you want to give your SSH key. Add your SSH key to your SSH agent so your terminal can actually connect to the server in the next steps: ```bash ssh-add ~/.ssh/my_hetzner_ssh_key ``` Then display and copy your __public__ key: ```bash cat ~/.ssh/my_hetzner_ssh_key.pub ``` And paste the contents into the "Add an SSH key" box in Hetzner. Give it an identifiable name, like `my_hetzner_ssh_key` or whatever you named it in the previous step. #### Add backups to your server Now, __optionally__, you may want to add backups to your Hetzner server. It's just a checkbox: if you check it, Hetzner will make a daily copy of your server so you can easily recover things in case things go wrong. RailsFast already offers a different database backup mechanism, but it's always a good idea to have contingency plans in case, so if you're a bit paranoid like me just check it and Hetzner will have your back in case things go really wrong. #### Run the initial server setup To finish setting up our server, let's run the RailsFast setup script to harden the security of the server and install dependencies. It's all streamlined, you just need to paste this into the "Cloud Config" text box: ```yaml #cloud-config runcmd: - bash -lc 'set -euo pipefail; wget -qO /root/railsfast-setup.sh https://setup.railsfast.com; chmod +x /root/railsfast-setup.sh; /root/railsfast-setup.sh 2>&1 | tee -a /var/log/railsfast-setup.log && reboot' ``` This will use Cloud-init to run the RailsFast server setup script upon creation, and get the server ready to deploy apps to. > [!NOTE] > This Cloud-init snippet runs the server setup script you can find at [setup.railsfast.com](https://setup.railsfast.com). If you prefer, you can do the initial setup manually to check the contents of the script. First, `ssh` into your server after it's created, then `wget` the setup script at `setup.railsfast.com`, inspect it, `chmod +x` it, and then execute it as root. The setup script installs Docker from Docker's official repository, creates the key-only `docker` deploy user Kamal connects as, hardens SSH and the kernel, configures the firewall, fail2ban, and automatic security updates, adds swap on small servers, and verifies everything at the end. It's idempotent (safe to re-run — that's also how you upgrade servers set up with older versions of the script). For everything it does, its optional knobs, and how to maintain your server afterwards, see the [[guides/server-setup-script|server setup script guide]]. You can explore the remaining server configuration options or just leave them as-is. Finish the process by giving a name to your server (like "railsfast-production" or something that helps you keep things organized), and then click the [ Create & Buy Now ] button. Your server will now start being created and initialized. > [!TIP] > **Terminal / agent lane**: the whole server creation is one [hcloud CLI](https://github.com/hetznercloud/cli) command. Save the Cloud Config snippet above as `cloud-config.yml` (anywhere — next to your project is fine), then: > ```bash > hcloud ssh-key create --name my_hetzner_ssh_key --public-key-from-file ~/.ssh/my_hetzner_ssh_key.pub > hcloud server create --name railsfast-production --type cpx22 --location hel1 --image ubuntu-24.04 \ > --ssh-key my_hetzner_ssh_key --user-data-from-file cloud-config.yml > ``` > The command prints the server's IP — that's the IP you'll use for DNS and `deploy.yml` below. > > **Authenticating `hcloud`**: generating the API token is the one human step (Hetzner Console → your project → Security → API tokens → Generate, with **Read & Write**). Interactive humans run `hcloud context create railsfast` and paste it. For agents, the non-interactive pattern: the human saves the token to a `chmod 600` file (e.g. `~/.config/railsfast/hcloud_token` — see the [[getting-started/6-ai-agents|secret handoff pattern]]), and every command reads it inline: `HCLOUD_TOKEN="$(cat ~/.config/railsfast/hcloud_token)" hcloud server-type list` — the token never touches the shell history or the chat transcript. > > Optionally (recommended), add Hetzner's Cloud Firewall as an outer layer in two more commands — see the [[guides/server-setup-script#the-firewall-truth-every-docker-host-should-know|firewall truth]]: > ```bash > hcloud firewall create --name railsfast-web --rules-file /dev/stdin <<'EOF' > [ > {"direction":"in","protocol":"tcp","port":"22","source_ips":["0.0.0.0/0","::/0"]}, > {"direction":"in","protocol":"tcp","port":"80","source_ips":["0.0.0.0/0","::/0"]}, > {"direction":"in","protocol":"tcp","port":"443","source_ips":["0.0.0.0/0","::/0"]} > ] > EOF > hcloud firewall apply-to-resource railsfast-web --type server --server railsfast-production > ``` #### Wait for the server to initialize We now need to wait for the server to boot up and the setup to complete (usually ~5 minutes) You can check the status of the initialization Cloud-init setup by `ssh`ing as `root` into your server and running: ```bash tail -f /var/log/cloud-init-output.log ``` Wait until you see a success message like `👽 SUCCESS: Setup complete!` and the system restarts. If you go and see the logs again after restarting, you'll now see cloud-init confirmation: `Cloud-init... finished at...` > [!NOTE] > If the success message never appears, the setup found a real problem and stopped (the server won't reboot in that case) — scroll up in that same log to see exactly which check failed. The script is safe to re-run after fixing it. > [!TIP] > **Terminal / agent lane**: no need to watch logs — there's a machine-checkable readiness probe. The setup script writes `/etc/railsfast-setup.version` only after every verification passes, and the one thing Kamal needs is the `docker` user talking to Docker. So poll for both, as the `docker` user: > ```bash > until ssh -o BatchMode=yes -o ConnectTimeout=5 -o StrictHostKeyChecking=accept-new \ > -i ~/.ssh/my_hetzner_ssh_key docker@YOUR_SERVER_IP \ > 'test -f /etc/railsfast-setup.version && docker info > /dev/null' 2>/dev/null > do sleep 20; done; echo "✅ server ready to deploy" > ``` > A fresh server typically turns ready in ~4–6 minutes. One timing subtlety: the probe can pass in the brief window **between setup finishing and the final reboot firing** — then SSH drops for a few minutes while the server restarts. Require two or three consecutive passes (a few seconds apart) before you actually deploy, and treat one `Connection refused` right after a pass as the reboot, not a failure. > > If it's still not ready after ~10 minutes, read `/var/log/cloud-init-output.log` on the server — the failed check will be right there. (Use your provider's web console for that if needed: after hardening, root SSH is key-only, and password login is off.) You can keep going with the next quickstart steps while you wait, just make sure your server is all ready before you actually deploy! ### Configure things for deployment Let's get ready to actually deploy your RailsFast project to your server! #### Point your domain's DNS to the server First, go to wherever you manage your domain's DNS, and point your domain to the server's public IP address you can find in Hetzner. For me, since I'm using Cloudflare to register and manage my domains, I navigate to my Domain, click on `DNS > Records`, and click the `[ + Add record ]` button to add a new record. Make the record of type `A` where the name is `@` (root) and the IP address is the server's IPv4 address from Hetzner. Leave the proxy status active (Proxied). If you also want `www.myproject.com` to work (recommended — people type it!), add a second record for it: another `A` record with name `www` pointing at the same IP (or a `CNAME` from `www` to `@`), Proxied too. You'll then list both hosts in `deploy.yml` in the next step. > [!TIP] > **Terminal / agent lane**: Cloudflare DNS is two `curl` calls with a scoped API token. The one human step is creating the token ([dash.cloudflare.com/profile/api-tokens](https://dash.cloudflare.com/profile/api-tokens) → Create Token → "Edit zone DNS" template, scoped to just your zone; add **Zone → Zone Settings → Read** to the token if you also want to verify the SSL mode in the next step). Hand it to the agent via the [[getting-started/6-ai-agents|secret handoff pattern]], then: > ```bash > CF_TOKEN="$(cat ~/.config/railsfast/cloudflare_token)" > # Find your zone ID: > ZONE_ID=$(curl -s -H "Authorization: Bearer $CF_TOKEN" \ > "https://api.cloudflare.com/client/v4/zones?name=myproject.com" | jq -r '.result[0].id') > # Create the A record (name "@" for the root domain, or a subdomain name like "app"): > curl -s -X POST "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records" \ > -H "Authorization: Bearer $CF_TOKEN" -H "Content-Type: application/json" \ > --data '{"type":"A","name":"@","content":"YOUR_SERVER_IP","proxied":true,"ttl":1}' > ``` > (`"ttl":1` means "automatic" — the right value for proxied records.) #### Select the right SSL encryption mode Then, still in Cloudflare, go to `SSL/TLS > Overview` and click on the `[ Configure ]` button. Select `Full (Strict)` encryption mode and click Save. > [!TIP] > **Terminal / agent lane**: read the current mode first — the SSL mode is a **zone-wide** setting, so if the domain already serves other sites (e.g. you're deploying to a subdomain), changing it affects all of them. Agents: check, report, and only change it with the human's blessing if it's not already `full` or `strict`. > ```bash > # Read (needs Zone → Zone Settings → Read on the token): > curl -s -H "Authorization: Bearer $CF_TOKEN" \ > "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/settings/ssl" | jq -r '.result.value' > # Set to Full (Strict) (needs Zone → Zone Settings → Edit): > curl -s -X PATCH "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/settings/ssl" \ > -H "Authorization: Bearer $CF_TOKEN" -H "Content-Type: application/json" \ > --data '{"value":"strict"}' > ``` > [!NOTE] > If you don't do set the right SSL mode in Cloudflare, even if your deployment succeeds, your server will complain about weird redirect errors like this: _"The page isn’t redirecting properly. Firefox has detected that the server is redirecting the request for this address in a way that will never complete. This problem can sometimes be caused by disabling or refusing to accept cookies."_ #### Edit `deploy.yml` to let Rails know where to deploy to After the `A` DNS record is set and the Full SSL encryption mode is set, go back to your RailsFast project in Cursor and edit the `deploy.yml` file you can find at `config/deploy.yml`. The `deploy.yml` is where we'll add all the details about your server and domain name so the app actually knows where to deploy to. Change the following things: - **IP**: Replace the placeholder IP of your server. The placeholder IP is `1.2.3.4`: search and replace, or replace manually in: - `servers` > `web` - `accessories` > `postgres` > `host` - `accessories` > `pg-backup` > `host` - **Domain**: Replace the placeholder domain for your actual domain name in: - `proxy` > `host` - If you added a `www` DNS record, serve both hosts by swapping the single `host:` key for a `hosts:` list: ```yaml proxy: ssl: true hosts: - myproject.com - www.myproject.com forward_headers: true ``` - **SSH**: Replace the placeholder SSH key path for the actual path of your Hetzner SSH **private** key in (example: `~/.ssh/my_hetzner_ssh_key`): - `ssh` > `keys` Now, optionally (but recommended): - [[guides/kamal-remote-builder|Configure a remote builder machine]] (if you're using macOS, you _may have to_ do this) - [[guides/kamal-container-registry|Configure a remote container registry]] > [!NOTE] > RailsFast uses Kamal for deployments. If you want to understand more about the `deploy.yml` file and what you can do re: deployments, check out the [Kamal docs](https://kamal-deploy.org/docs/configuration/overview/) > [!CAUTION] > **If you add custom Kamal accessories** (Redis, Elasticsearch, etc.) beyond the ones included in the RailsFast template, make sure to bind their ports to `127.0.0.1` — e.g. `port: "127.0.0.1:9200:9200"` instead of just `port: "9200:9200"`. By default, [Docker published ports bypass ufw rules](https://www.baeldung.com/linux/docker-container-published-port-ignoring-ufw-rules), which means your accessory could be exposed to the internet even though the firewall is active. The default RailsFast accessories are already configured safely, this only applies to new accessories you may add. ### Hit the deploy button We're ready to deploy! Let's boot up the database and its scheduled backups: ```bash bin/kamal accessory boot all ``` If you're not using a [[guides/kamal-container-registry|container registry]], launch the Docker Desktop app (to run Docker engine). Then, run this to set up and boot the local registry (and confirm your container registry credentials are okay if you're not using a local registry): ```bash bin/kamal registry setup ``` > [!NOTE] > If `registry setup` hangs for minutes with no output, it's almost always local Docker Desktop in a wedged state (telltale sign: `docker ps` responds instantly but `docker start` hangs on anything). Restart Docker Desktop, remove the half-created container if one exists (`docker rm -f kamal-docker-registry`), and re-run. And then, finally, deploy your project with: ```bash bin/kamal deploy ``` ✅ Success! This gets your RailsFast app live. Visit your domain name on a web browser and you'll see the live demo page. > [!TIP] > This is a good time to share your project online! If you use X (formerly Twitter), please [post about it](https://x.com/intent/post?text=I%27ve%20just%20built%20my%20first%20project%20using%20@RailsFast%20by%20@rameerez!%20Check%20it%20out!&url=https://yourproject.com) and tag me so I can see it! 🚀 That's it! Your project is now running and living on the internet for about $5/mo, ready for full production usage, fully secured, on your own VPS that you fully control, where you own all your code and users' data, with no surprise $10k Vercel monthly bills! Welcome to freedom! ## What you just got I think it's worth repeating and making explicit everything you already have up and running now, because it's remarkable. Other templates require you several external dependencies, subscriptions to different services, and configuration headaches for something we've achieved here in under 15 minutes. You now own: - ✅ A full-stack web application (not just a frontend decoy: both front and back-end) fully ready for production traffic - ✅ Running on your own domain - ✅ Secured with a **SSL certificate** (automatically issued on deployment and auto-renewed before expiration) which makes all client-server communications secure and strictly encrypted - ✅ A fully secure, production-ready **database, DB schema, and migrations**; all this with no external dependencies: you own all your user's data, nothing leaves your server, nobody can nuke your business by banning your Supabase account (because you don't need one!) - ✅ **User authentication / login** working by default, without any external dependencies either - ✅ **Billing + payments**: everything ready to start charging real money as soon as you configure your Stripe credentials in the next sections - ✅ You're running your entire business on your own server for a **flat fee of less than $10/mo**, without having to worry about getting traffic spikes (unlike Vercel!) - ✅ You can even fit **multiple RailsFast projects** in the same server, without having to pay more! - ✅ Your project is now ready to support thousands of users, right out of the box - ✅ Easily scalable to millions of users as your app grows (and even then, you won't be paying a fortune because [servers are cheap](https://rameerez.com/how-i-exited-the-cloud/)!) - ✅ Plus all the features you can find in the features section of the docs, ready to go! --- This quickstart may feel like a lot of steps, but it's only a one-time setup! Moving on, every time you want to deploy your app, you just need to run `bin/kamal deploy` You're now ready to start developing (or vibe coding) your app! Your next step is customizing the base project to start making your app actually look like your own app. --- ## Rebrand & customize If you're here, you should have been able to complete the quickstart and you should have your RailsFast base project already deployed and live. That's huge! You have a live, secure, monetizable demo page that screams "build on me!", so let's get to it! It's time to own your app. Let's start by customizing the base project to make it truly yours. ## Rebrand the project Customizing your brand name, logo, company name etc. in RailsFast is extremely straightforward: you just need to edit the `railsfast.yml` under `config/railsfast/railsfast.yml`. Since you already have a name and domain name, go ahead and edit the file. While you're at it, you can also add your project logo, your default page title and description for SEO, and your legal address for invoicing. > [!TIP] > While you're setting branding materials, don't forget to add a default OG image on `railsfast.yml`. An OG image is the image showed on apps like X, Telegram, Discord, Facebook, etc when you share a link. They're usually 1200x630px. OG images [are notoriously important](https://x.com/levelsio/status/1967622683480441106) for CTR and looking professional when people share your project. When you're done, you can update the live website by first git commiting your changes, and then deploying the project with: ```bash kamal deploy ``` And if you just want to see the changes in your computer without updating the live website, just fire up the local development server: ```bash bin/dev ``` And your project will be available in your computer only at [`http://localhost:3000`](http://localhost:3000) ## Add a favicon While we're at it, there's one last thing we can do to make your RailsFast project professional-looking and properly branded: add a favicon. The favicon is the little icon that shows up in the browser navbar next to your website title, and in mobile browsers when you save the page as a bookmark, browse tabs, etc. If you already have a logo for your project, this should be quite straightforward. I recommend you use a favicon generator [like this one](https://www.favicon-generator.org), because it already gives you not only the favicon, but the mobile iOS / Android app icons, and icons in different sizes, all RailsFast-compatible. Just generate your favicon pack and replace the contents of `public/favicon` in your RailsFast project with your own generated assets. --- ## Configure credentials The next thing that's stopping us from charging money and delivering our software service right away is configuring the app to use our credentials. We need, for example, to configure our Stripe API keys in the project so we can start processing payments; or our S3 access keys so we can upload and save attachments like user avatars. ## Credentials are secure and encrypted Credentials in RailsFast are encrypted. They can only be decrypted with the random encryption key (`config/master.key`) that was generated uniquely for you upon project setup (when we ran `bin/railsfast init` in the quickstart). This is a secure way to store private & sensitive credentials like access and API keys, without risking leaking them in plaintext in the codebase. No one can read your credentials even if they get access to the encrypted file (`credentials.yml.enc`), because they don't have the decryption key. > [!WARNING] > Never commit or make public your `master.key` file. It is already in the `.gitignore` to avoid it getting accidentally commited, so don't change that. Use the same caution for any other `.key` file in the project. ## How to edit the encrypted credentials file The RailsFast setup also generated a sample credentials file for you, to help you understand how the credentials file should be used in your project. You'll see it's very straightforward. To view and edit your project credentials, run: ```bash EDITOR="cursor --wait" bin/rails credentials:edit ``` And you'll view the decrypted credentials, ready to edit. To save changes after editing, just save the file and close it, and the credentials will automatically get encrypted back, ready to be used by your app. > [!TIP] > **Terminal / agent lane**: `credentials:edit` re-encrypts whatever text your `$EDITOR` leaves in the tempfile — so a *script* can be the editor, and the edit can be fully non-interactive: > ```bash > # set-stripe-key.rb — surgical TEXT substitution on the decrypted tempfile > # text = File.read(ARGV[0]) > # abort("pattern not found — file untouched") unless text.sub!("private_key: sk_live_abc123", "private_key: #{ENV.fetch("STRIPE_KEY")}") > # File.write(ARGV[0], text) > EDITOR="ruby set-stripe-key.rb" bin/rails credentials:edit > ``` > The **one iron rule**: treat the decrypted credentials as **text, never as data**. Loading the YAML into a hash and dumping it back destroys every comment, every `&anchor`, and every `<<:` merge in the file — irrecoverably. A text-substitution edit like the one above is verified byte-identical everywhere except the line it changes. Have the script `abort` (non-zero, before writing) when its target pattern isn't found: aborting without writing leaves the file untouched, so a mismatched assumption can never half-edit your credentials. Read credentials with `bin/rails credentials:show` — never by decrypting files yourself. ## How the credentials file is organized If you look at the credentials file, you'll see it's organized by environments: `shared`, `production`, and `development`. You may (and probably should) have different credentials for your development and production environments. For example, for Stripe, you should use your sandbox / test keys in development; and you should only use the live keys in production, exclusively. > [!INFO] > "Production" just means "your server": the environment where your project is live and ready to be used by other people. As opposed to "development": where the project is only running in your local development computer, and only accessible by you. For some other services, this `production` vs `development` distinction may not make much sense, so to avoid duplication you can just put those credentials under the `shared` block, and both `production` and `development` will get them automatically. I've included a sample `demoservice` to showcase how shared credentials work. You can also override any part of a shared credentials block like `demoservice` per environment, while the rest will be kept the same. In any case, `demoservice` is just there for demoing purposes: you can remove it. > [!INFO] > Your credentials also include an `active_record_encryption:` block with keys `bin/setup` generated for you. You don't need to touch it — see [[features/8-encryption|Encrypting sensitive data]] for what it's for and how it works. ## Add all the necessary credentials If you followed the [[getting-started/1-prerequisites|Prerequisites]] section, you should already have accounts in all the key services we're going to use, so now it's just a matter of going service by service generating the right keys and plugging them in the right place. For your RailsFast app to fully work, we'll need at least: - **AWS**: we'll just use AWS SES for sending emails. I'm not a fan of AWS, but SES is probably the cheapest & most reliable emailing service out there. - Inside your AWS Dashboard, navigate to `AWS SES` (Simple Email Service) - Add your domain in SES > Configuration > Identities and create all required DNS records - For more detailed instructions, follow our [[guides/email-deliverability|perfect emailing guide]] and you'll also achieve a 10/10 deliverability score in your emails - Now move away from SES and navigate to AWS IAM to generate an Access Key and Secret Access Key for SES - Create a new IAM user - Name it something descriptive like `yourprojectname`-production - Give it enough SES permissions to send emails - To do that, create a new policy, click on the [ Create policy ] button - In the "Specify permissions window, switch from "Visual" to "JSON" and paste the following contents: ```json { "Version": "2012-10-17", "Statement": [ { "Sid": "VisualEditor0", "Effect": "Allow", "Action": [ "ses:SetIdentityMailFromDomain", "ses:SendEmail", "ses:SendTemplatedEmail", "ses:SendCustomVerificationEmail", "ses:SendRawEmail", "ses:SendBulkTemplatedEmail", "ses:SetIdentityDkimEnabled", "ses:SetActiveReceiptRuleSet" ], "Resource": "*" } ] } ``` - Give it a descriptive name like `ses-send-email-policy` and create the policy - Back on the create user screen, attach the new created policy to the user, and create it - Once the user is created, click it to access the user details screen, navigate to the "Security credentials" tab, and create a new Access Key. When asked, select "Other" as your reason (your reason is direct AWS API calls, but that's not listed) - Create the Access Key and copy the access key and private key to your RailsFast project's credentials file > [!TIP] > **Terminal / agent lane**: the entire AWS SES setup is CLI-able — no dashboard needed. With an authenticated `aws` CLI: `aws iam create-policy` (the JSON above) → `aws iam create-user` → `aws iam attach-user-policy` → `aws iam create-access-key` (straight to a `chmod 600` file, never echoed). Then the identity: `aws sesv2 create-email-identity --email-identity myproject.com --dkim-signing-attributes NextSigningKeyLength=RSA_2048_BIT` (returns the three DKIM tokens) and `aws sesv2 put-email-identity-mail-from-attributes --email-identity myproject.com --mail-from-domain mail.myproject.com --behavior-on-mx-failure USE_DEFAULT_VALUE`. The DNS records from the [[guides/email-deliverability|deliverability guide]] (3× DKIM CNAME, the MAIL FROM MX + SPF TXT, DMARC TXT) go in via the Cloudflare API — same pattern as the [[getting-started/2-quickstart|quickstart DNS lane]], all DNS-only (not proxied). Verified in ~2 minutes. > > One gate to check first: `aws sesv2 get-account --query ProductionAccessEnabled` — a fresh AWS account starts in the SES **sandbox** (can only send to verified recipients). Requesting production access is a human step (support case, up to 24h); in the meantime `aws sesv2 create-email-identity --email-identity you@example.com` verifies individual recipients for testing. - **Cloudflare**: DNS + DDoS protection, Turnstile for anti-bot signups, R2 for S3-compatible storage (for storing avatars, user uploads, etc.) - R2: [create an R2 bucket](https://developers.cloudflare.com/r2/api/tokens/) for your project. Then, on the R2 Overview screen, under the Account Details box, you'll see the "API Tokens" row and next to it, a `{Manage}` button, click it. Now in the Manage R2 API tokens screen, create a User API Token for your bucket (give it `Object Read & Write` permissions, click "Apply to specific buckets only" and specify just your bucket). Create the API Token and copy and paste the `account_id`, `access_key_id`, `secret_access_key` and bucket name, and paste them in the right place in your credentials file. Go back to the R2 Overview screen, copy your Cloudflare account ID and paste it in the corresponding spot in the credentials file too. You only need to do this for `production`, not for `development`. - Turnstile: [create a Turnstile widget](https://developers.cloudflare.com/turnstile/get-started/widget-management/dashboard/) to get your Turnstile site key and secret key. This will be used to protect your auth / signup forms from bots. As with R2, Turnstile credentials are only needed in `production`, not in `development`. You just need the site and secret keys, you don't need to do anything else with the widget, all is already configured and wired in RailsFast for you. > [!TIP] > **Terminal / agent lane**: R2 + Turnstile take exactly **one** human step — an account-scoped API token (Cloudflare dashboard → profile → API tokens → Custom token → **Account → Workers R2 Storage → Edit** + **Account → Turnstile → Edit**), handed over via the [[getting-started/6-ai-agents|secret handoff pattern]]. Everything else is three API calls: > ```bash > CFA="$(cat ~/.config/railsfast/cloudflare_account_token)" > # 1. Create the bucket (locationHint is advisory — Cloudflare may place the > # bucket in a nearby region; options: weur/eeur/wnam/enam/apac): > curl -s -X POST "https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/r2/buckets" \ > -H "Authorization: Bearer $CFA" -H "Content-Type: application/json" \ > --data '{"name":"myproject","locationHint":"weur"}' > # 2. The token IS your S3 credential pair (documented R2 pattern): > # access_key_id = the token's ID: curl -s .../user/tokens/verify | jq -r .result.id > # secret_access_key = SHA-256 of the token value: printf '%s' "$CFA" | shasum -a 256 > # 3. Create the Turnstile widget (returns sitekey + secret): > curl -s -X POST "https://api.cloudflare.com/client/v4/accounts/$ACCOUNT_ID/challenges/widgets" \ > -H "Authorization: Bearer $CFA" -H "Content-Type: application/json" \ > --data '{"name":"myproject","domains":["myproject.com"],"mode":"managed"}' > ``` > Then write the values into the `production` block non-destructively (see the credentials tip above). - **Stripe**: payment processing - [Generate a publishable and a private key](https://docs.stripe.com/keys) (both in live and sandbox mode, for `production` and `development`, respectively) - [Create a webhook](https://docs.stripe.com/webhooks) in live mode (webhook URL: [`https://YOUR-DOMAIN-HERE/pay/webhooks/stripe`](https://github.com/pay-rails/pay/blob/main/docs/7_webhooks.md)), and add the webhook signing secret in `production` - Install the [Stripe CLI](https://docs.stripe.com/stripe-cli/install) and add the development webhook signing secret to `development` > [!TIP] > **Terminal / agent lane**: after a human runs `stripe login` once (browser step), the CLI holds test + live publishable/restricted keys in `~/.config/stripe/config.toml` (`chmod 600` — agents read it, never echo it). From there: **development** credentials are fully agent-obtainable (test keys from the config file, dev webhook secret via `stripe listen --print-secret`); **sandbox and live webhook endpoints** are one CLI call each (`stripe webhook_endpoints create --url https://yourdomain.com/pay/webhooks/stripe ...` — the response includes the `whsec_` signing secret). The one per-account human step for production: a dashboard-created **restricted key** for the app (the CLI's own live key expires in 90 days and can't do live writes — by design). Running several projects? Read the [[guides/stripe-organizations|Stripe Organizations guide]]: one org, one account per project (copying your legal entity — no repeat KYC), and optionally one `sk_org_` key that automates every project account. ## Configure database backups RailsFast ships by default with automatic database backups. All your user's data gets backed up regularly outside of your server, so in case of disaster you can recover all your data and put your app back up. However, for this to work you need to let your app know **where** those backups should be uploaded to. Luckily for us, we can use any S3-compatible storage solution for this. So, since we already have an AWS and a Cloudflare account, we could just use AWS S3 or Cloudflare R2 for our backups and be done with it. There's nothing wrong with that. However, if you're like me and you want to add an extra bit of resilience to your setup (in the improbable case your AWS or Cloudflare accounts get compromised because you're already using them for other things), you can keep your backups in a completely different service that we're only going to use for backups. I use [Backblaze B2](https://www.backblaze.com/cloud-storage) for that. Backblaze B2 is S3-compatible just like AWS S3 or Cloudflare R2, and they also have a free tier that makes your backups free or close to free as long as they're not too big. Whatever the service you end up using for storing backups (again, AWS S3 and Cloudflare R2 are both good options if you don't want to complicate things early), you just need to add the S3-compatible credentials in the designated files under `config/backups`: - `s3_access_key.key` - `s3_bucket.key` - `s3_region.key` - `s3_secret_access_key.key` These credentials are the same for any S3-compatible storage service. Just replace the placeholder text in each file with your credentials, re-deploy the backups service with `kamal accessory reboot pg-backup`, and you'll be automatically backing up your entire production database every night. > [!IMPORTANT] > **Using S3 or R2 instead of Backblaze B2?** One more thing: the upload endpoint defaults to a B2 hostname. On current templates, write your provider's endpoint into an optional `config/backups/s3_endpoint.key` (S3: `s3.us-east-1.amazonaws.com` — R2: `.r2.cloudflarestorage.com`) and it takes precedence. On older templates (no `s3_endpoint.key` support in `.kamal/secrets`), edit the `HOST_BUCKET=` line in `.kamal/secrets` directly — otherwise your backups will silently target the wrong host. > [!TIP] > **Verify your backups by RESTORING one, not by listing the bucket.** A dump in S3 proves upload; only a completed restore proves recovery. Trigger a backup manually, then walk the [[guides/restoring-backups|restore runbook]] against a scratch database — about fifteen minutes, once, and you'll actually know: > ```bash > bin/kamal accessory exec pg-backup --reuse "/backup-scripts/backups.sh" > aws s3 ls s3://your-backups-bucket --recursive # confirm the dumps landed… > # …then do the scratch-database restore drill from the runbook guide > ``` If you want to adjust when to do backups, how often to do them, and how many backups to keep, you just need to adjust the `pg-backup` config in `config/deploy.yml`. Make sure to reboot the backups service after you make changes: ```bash bin/kamal accessory reboot pg-backup ``` > [!INFO] > We're using the [kartoza/pg-backup](https://github.com/kartoza/docker-pg-backup) Docker image for database backups. It's already wired up and configured to work with your credentials. It's also configured as a Kamal accessory so you can easily manage it as shown above. Read their docs to know more about available backup options. --- ## Developing your app Look at you, you already have a beautiful working project! Everything is wired and configured. And it's even live on the internet for other people to see and interact with! You've deployed your app and have something live before even starting development, isn't that beautiful? Now it's just a matter of building __your own__ business on top of the template. To begin development, just run: ```shell bin/dev ``` And the local development server will start up, serving everything needed for you to see your project in localhost (at port 3000 by default) -- just visit [http://localhost:3000](http://localhost:3000) > [!IMPORTANT] > Make sure you're running your local PostgreSQL server (on macOS: by launching the [Postgres app](https://postgresapp.com/) and starting a v15+ server; if you're on Windows follow the [Windows instructions](https://www.postgresql.org/download/windows/)). Without a PostgreSQL database, your local Rails app will error upon launch.

RailsFast Email Deliverability Guide

## Start coding or vibe coding RailsFast comes ready for you to vibe code on top of it. It ships with smart context rules so that Cursor, Claude Code, Codex etc. can easily understand the structure and contents of the project, and can use the right tools in the appropriate subparts of the project. It understands the technologies used, where they're used, and how to fetch detailed docs, so vibe coding on top of it should be a breeze. Just ask Claude Code or Cursor whatever you want to do. For example, let's ask AI to create a new "Help" page for us, wire it with the appropriate routes and controllers, add it to the navigation bar and footer, and fill it with useful help content from the FAQ. ### Your first vibe coded feature To get started, we could just ask AI something like: ```plaintext create a new demo "Help" page, add it to the navbar and footer, use the FAQ component as main content, add a title and description and SEO meta tags too ``` > [!TIP] > If you're using Cursor, make sure it's on "Agent" mode (not "Plan" or "Ask") so it can actually browse and edit your project files for you. Let the AI agent browse through your project and create + edit the necessary files. When it's done, it should have done something similar to this: - Added a new route to the `config/routes.rb` file - Created a new view under `app/views/demo/help.html.erb` (or similar) and filled it with content - Added the controller action to `app/controllers/demo_controller.rb` - Edited the navbar and footer partials at `app/views/layouts/partials/_navbar.html.erb` (and footer) Now if you visit and reload your project at [http://localhost:3000](http://localhost:3000), you'll see the new Help page in the navbar and footer, and you should be able to navigate to it if you click on it. ### Your second vibe coded thing Another idea would be to ask AI to edit the default landing page and make it yours. AI can replace the placeholder text and generate landing copy text specific to your project, so you can just move really quick to put something out there. I would ask the AI agent something like: ```plaintext my startup idea is: [describe it]. With this info, please edit @app/views/demo/landing_page.html.erb to replace the placeholder text and tailor the landing page to my startup idea. Use any components under @app/views/components/railsfast. There's no need to use all components, nor any need to keep any of the existing components. No need to keep any of the placeholder content: edit, delete, and mix the available UI components as needed. Just tailor the landing page to the idea in the best way possible ``` Of course, the resulting AI-generated text will be pretty bland, and will look placeholder too. I strongly recommend against this approach for your actual project landing page: for the long-term you'll need an actually well-researched, well-thought-out, well-written landing page for your startup, with your actual product images. But for demo purposes I think this is a really quick way to get started: all of a sudden you can have a fully branded landing page, tailored to your specific product offering, ready in theory to start charging money. You get the hang of it -- so go ahead and start building! ### Add the Context7 MCP One of the most important, if not **the** most imporant thing you can do when vibe coding is give the AI good context. Context about your app, context about your architecture, context about the APIs and dependencies you're using. RailsFast ships with already good context by default, but to make it work you need to connect to the Context7 MCP: a service that will help your AI get updated docs about any of the APIs and dependencies your project is using. To do that, first grab your [free Context7 API key](https://context7.com/docs/api-guide), and then configure your AI to use it: For example, if you're using Claude Code just run: ```bash claude mcp add --transport http context7 https://mcp.context7.com/mcp --header "YOUR_API_KEY" ``` If you're using Cursor or other IDEs, you can learn how to install it on the [Context7 GitHub page](https://github.com/upstash/context7). ### Do small, incremental steps Don't try to vibe code your whole project at once! Break big milestones into extremely simple tasks that the AI can achieve without problem. Maybe have the AI edit 1-2 files at the time, at most! Commit often to GitHub. Don't wait until you have 20 or 30 edited files to commit! Do very small, incremental steps, test them, and commit the changes to GitHub before moving on to the next thing. ### Deploy often! Your main goal while developing should be to press _the deploy button_ as much and as often as possible: ``` kamal deploy ``` Here's the workflow: ask AI one tiny thing, review the code and verify everything works, commit and hit `kamal deploy`. Rinse and repeat. ### How your RailsFast project is structured For development (no matter if you're coding manually, reviewing AI-generated code or managing AI agents), you'll need to have a rough notion of where things are. If you're already familiar with Rails, you already know this. If you're new to Rails, this is what your RailsFast project (and every other Rails project) is structured like: - **Front-end** - [Views](https://guides.rubyonrails.org/action_view_overview.html): - All visible pages ("HTML") are called `views` - Views live under `app/views` - Rails uses `erb` for views. `erb` is just HTML with Ruby code. It's somewhat similar to what PHP code looks like. - If a view file name starts with underscore (`_`), it's called a partial. Example: `_navbar.html.erb`. Partials are like components, you can include and reuse them in other views. - [Controllers](https://guides.rubyonrails.org/layouts_and_rendering.html#overview-how-the-pieces-fit-together): - Every view (except partials) needs a corresponding `controller` - A `controller` is just what sits between your backend/database and your views - The controller fetches data and gets your views ready for display - Controllers live under `app/controllers` - The name of the controller needs to match the name of the folder the view is in - Example: `demo` -- If your view is `app/views/demo/help.html.erb`, the corresponding controller needs to be `app/controllers/demo_controller.rb` (note how the `demo` part is identical in both file names) - The controller needs to define a **controller action** (a method inside the controller file) whose name needs to match the name of the **view** file - If your view is `help.html.erb`, inside your controller you need to define the `help` method - You can define special "`@` variables" in your controller action. If a variable name inside your controller action starts with `@`, you can use it right away in the corresponding view without doing anything else. Use this to, for example, load data and display it in the view. - Interactive pages and JavaScript - Rails comes with a suite of tools called [Hotwire](https://hotwired.dev) to make your pages interactive (not just static) - For interactive pages, Rails uses [Hotwire Turbo](https://turbo.hotwired.dev): a way of modifying / deleting / adding HTML to your pages in a few lines of Ruby code, without needing any JavaScript. - If you do need JavaScript, Rails uses [Hotwire Stimulus](https://stimulus.hotwired.dev) by default. Stimulus controllers let you add JS functionality to your views with the minimal amount of JavaScript needed. - You can also add React, Vue, Svelte, or any other JS framework / library to your Rails project. I do not recommend this, though, I believe you'll find both Turbo and Stimulus work way better when used within a Rails project than any JS framework - To add any JS library to your Rails project, use `importmaps` - For example, if you want to use Three.js in your project, just run `bin/importmap pin three` and Three.js will be available to use in your Stimulus controllers - **Back-end** - [Models](https://guides.rubyonrails.org/active_record_basics.html) - In Rails, you don't interact with your SQL database directly - Database tables are abstracted into **models** (using something called `ActiveRecord`) - A `model` is just a database table abstraction + related logic - Models live under `app/models` - Example: if you have a `user` table in your database, the corresponding model is `User` and lives under `app/models/user.rb` - Most of the logic in your entire project should live in the models ("heavy models, thin controllers") - Models can be interacted within Rails like `User.last`, or `User.last.created_at` - Models can (and should) validate data and enforce lengths, limits, etc. - Models can define relationships between other models (like `has_many`, `belongs_to`... which map to DB relationships) - You can still write SQL queries in Rails, but it's usually easier to just use model methods provided like `ActiveRecord` - For example: `User.last.posts.count` joins the `post` table with the `user` table and gets you the post count for the last user - Database - PostgreSQL - PostgreSQL (sometimes called `psql` or `postgres`) is the database of choice in RailsFast - PostgreSQL is robust, it's the industry standard, and it scales beautifully - It has very powerful plugins to do things like vector embeddings for AI, or geolocation things, etc. - PostgreSQL has pretty much everything you startup will ever need, even if you reach millions of users - Since you don't really interact with the database directly in Rails, you can in theory swap PostgreSQL for any other database like MySQL, SQLite, MariaDB, etc. This is NOT recommended in practice, but just know that it's in theory possible - Migrations - You never touch the database directly - You alter the state of the database through database migrations - Migrations live under `db/migrate` - Migrations define changes in the database, like new tables or fields, new relationships, new indices, or deletions - You generate a new migration with `rails g migration` - You run pending migrations with `rails db:migrate` - The current state of your database after all migrations is always available in `db/schema.rb` - You never touch the database directly (again, worth repeating, because if you do you'll be bypassing all model validations and callbacks and your database may get to inconsistent states. You do everything through migrations and through `ActiveRecord` model logic) - Services / POROs - Some logic may not belong to any specific model, and it may not belong to any view/controller either - For that kind of logic, using services is usually a good idea - A `service` is just a PORO (Plain Old Ruby Object): that is, a default Ruby file without any required Rails logic - Services live under `app/services` - Example of services: a thin wrapper over an API you're using (like `openai_service.rb`), codebase-wide utilities, etc. - Background / async jobs - Background jobs go in `app/jobs` - You can set scheduled, recurring jobs (cron-like) by editing `config/recurring.yml` - Background jobs work thanks to a component of Rails called `solid_queue` - **Config** - [Routes](https://guides.rubyonrails.org/routing.html) - Your routes are defined in `config/routes.rb` - The routes file just link specific routes like `/help` to the corresponding controller action - You can do all sorts of things with routes: conditionals for auth / logged in users, dynamic routes by matching params, etc. - [Deployments](https://kamal-deploy.org/docs/configuration/overview/) - We use Kamal for deployments - Everything related to your deployments (servers, domain name, etc.) is configured in the `config/deploy.yml` file - Environments - Advanced configuration for your app can be found at `config/application.rb` - Environment-specific overrides can be made at `config/environments/development.rb`, `config/environments/production.rb`, etc. - None of this is needed to get you started, everything has been already configured in RailsFast, just know that these files exist - Gem-specific configs - Under the `config` folder you'll usually find configuration files for gems (gems are software packages in Ruby, like libraries) This is just a quick overview of what a Rails project looks like so you can get up to speed fast. It is by no means a comprehensive list of everything Rails has, I just wanted to give you the right mental models to get you to 80% understanding in a few minutes. If you want to go in depth, I recommend reading the [official Rails guides](https://guides.rubyonrails.org/getting_started.html) for getting started with Rails, or better yet: asking ChatGPT/Grok any questions you may have. ## Convention over configuration A tip before I send you off: when I started programming on Ruby on Rails, I remember feeling many things worked "like magic". There's many things in Rails that are wired implicitly behind the scenes, things you're not explicitly aware of, and the first feeling people usually get is that those things just work by means of magic. It can get frustrating at first because in other programming languages and frameworks you're used to wiring everything explicitly, and you may not know how things are even working in Rails, or where they're actually connected. The answer is **convention over configuration**. This is one of the main principles of Rails, and in practice it just means that all Rails projects look pretty much the same. Views go under `app/views`. Controllers go into `app/controllers`. Views names and controller actions need to be named the same. If you get comfortable with these conventions, you will understand any Rails database out there in minutes. You can onboard and get up to speed with any new project in literally minutes. It's one of the many advantages of Rails. Here's an anecdote: in the past I've hired Rails developers. Once, this one new Rails dev pushed code to production within 30 minutes of first onboarding the project (not an exageration) -- just because all Rails projects look alike. He was literally outputting production-ready code and being profitable for the company within 30 minutes of him first sitting as his desk. He hadn't seen the codebase before and was not familiar with the project. He simply just understood intuitively where everything was thanks to Rails and its principles. I still have to see something remotely similar in any other programming language / framework. If you ever wonder how things are wired up in Rails or how Rails knows this thing is related to that other thing, the trick is usually **naming**. When two things are named the same (or similar, like singular/plural etc.) those things are usually related (and wired behind the scenes) like views and controllers. ## It's now your turn! You now have all the tools you need with succeed with RailsFast! There's not much more I can say to help you onboard: you should now have a live project, fully configured, and ready to go! It's now your turn to start exploring and building. Don't be afraid to break things (commit to GitHub often!), and don't be afraid to ask silly questions to the AI, just go for it! >[!TIP] The RailsFast docs come in LLM-friendly format! Just feed our [LLMs.txt](/llms.txt) file to any AI and ask it questions! The rest of the docs cover specific features within RailsFast. There's a lot of functionality that comes preconfigured in RailsFast that _just works_ so you don't need to worry much about it: auth, user uploads, backups, sending emails, payment processing integration, etc. But there's also many optional things you can easily "turn on" and leverage to build your project as if you were stacking LEGO pieces. The rest of the sections cover this. The next section, in particular, goes over the different features that come with RailsFast. Feel free to keep reading the docs in order, or just jump straight to the section that draws your attention the most! Good luck! -[Javi](https://x.com/rameerez) --- ## For AI Agents RailsFast is built to be driven by AI agents end to end — not as a marketing line, but as a set of testable guarantees. An agent has taken a fresh clone to a branded, deployed production app with the human only touching secrets. This page is the contract that makes that repeatable. Why it works so well: **convention over configuration is the best agent interface ever shipped.** Every Rails app looks the same, so your agent has effectively already seen your codebase. No build step, no `node_modules` maze, boring omakase technology — fewer places for an agent to get lost. ## Point your agent at these - **Docs, LLM-shaped**: [railsfast.com/docs/llms.txt](https://railsfast.com/docs/llms.txt) — the entire documentation as one plaintext file. - **In-repo context**: every clone ships `AGENTS.md` / `CLAUDE.md` plus `.cursor/rules/` describing the stack and quality bar. Claude Code additionally auto-discovers the bundled **`railsfast` skill** (`.claude/skills/railsfast/`) — the workflow verbs and the gotchas that aren't guessable from the file tree. - **MCP**: the project serves its own state over the Model Context Protocol. One line in any MCP-capable host: ```json { "mcpServers": { "railsfast": { "command": "bin/railsfast", "args": ["mcp"] } } } ``` Tools: `doctor` (production readiness), `status` (template sync state), `project_info` (app identity + docs pointers). Read-only, stdlib-only, zero config. ## The guarantees 1. **Non-interactive everything**: `bin/railsfast init --yes --name "My App"` takes a fresh clone to a configured, committed, database-ready project with zero prompts. The name is kept exactly as typed. 2. **One deterministic gate**: `bin/ci` runs RuboCop, dependency audits, Brakeman, the full test suite, system tests, and seed verification. It keeps going past failures and reports everything at the end — one command, one exit code, no judgment calls. 3. **Machine-readable state**: `.railsfast/config.json` records what the project is and which template commit it's based on; `bin/railsfast status` and the MCP `status` tool read it back. 4. **Self-diagnosing setup**: `bin/railsfast doctor` (or `--json`) lists everything still unconfigured — placeholder credentials per service, deploy config, backups, branding — and **every finding carries its own fix**. Exit `0` means production-ready; branch on it. 5. **Deploys are reproducible**: `bin/kamal deploy` builds from git HEAD via a clean clone. If it's not committed, it doesn't ship — an agent can reason about exactly what's live. 6. **Verifiable health**: after a deploy, `https://yourdomain.com/healthcheck` ([[features/5-monitoring|allgood]]) is the agent's post-deploy assertion. ## Day zero: from one sentence to a live site The entire [[getting-started/2-quickstart|quickstart]] is agent-executable. The human's job is one sentence — **"I want to build MyProject on myproject.com"** — plus answering a few questions and creating exactly two API tokens. Everything else, the agent does: it even installs its own tools (`brew install hcloud`) if they're missing. ### What the agent should ask before touching anything An agent driving the quickstart should qualify the run first (one batch of questions, not twenty interruptions), and always recommend a good default: 1. **Name + domain** — if not already given in the starting sentence. Deploying to a subdomain of a domain the human already has on Cloudflare is the fastest path; a fresh domain is a human purchase (agents can't buy domains). 2. **New server, or reuse one?** — new costs real money: quote the actual price (`hcloud server-type describe ...`) and get an explicit yes **before** `hcloud server create`. Reuse means: which IP, and skip server creation. 3. **Which location?** — physically close to the app's users. Check live stock first: cheap models come and go per location (`hcloud server-type list`). 4. **SSH key: generate fresh, or reuse?** — recommend a fresh per-project `ed25519` key (`ssh-keygen -t ed25519 -f ~/.ssh/myproject_hetzner -N ""`): revocable independently, no passphrase prompts to block automation. Reusing an existing key is fine if the human prefers it. 5. **Remote builder?** — on Apple Silicon the answer is almost always yes ([[guides/kamal-remote-builder|why]]): that's a second, small server (same cloud-config), so include it in the spend quote. 6. **Where does the private GitHub repo live?** — `gh repo create` under which account/org. ### The two human steps Both are "create an API token while logged in" — genuinely human (password + 2FA), one minute each: - **Hetzner**: Console → your project → Security → API tokens → Generate (**Read & Write**). - **Cloudflare**: [profile → API tokens](https://dash.cloudflare.com/profile/api-tokens) → Create Token → "Edit zone DNS" template → scope it to the one zone, and add **Zone → Zone Settings → Read** (plus **Edit** only if the agent may need to set the SSL mode). Where a step truly needs a human, the agent should say exactly what to click and what scopes to grant — like above — and nothing more. ### The secret handoff pattern Tokens should never be pasted into a chat: transcripts get logged, summarized, and sometimes shared. The pattern that keeps secrets out of band — the human copies the token to the clipboard, then runs: ```bash mkdir -p ~/.config/railsfast && pbpaste > ~/.config/railsfast/hcloud_token && chmod 600 ~/.config/railsfast/hcloud_token ``` (macOS `pbpaste`; Linux: `xclip -o` or `wl-paste`.) Same for `cloudflare_token`. The agent then reads it inline where needed — `HCLOUD_TOKEN="$(cat ~/.config/railsfast/hcloud_token)" hcloud ...` — and never echoes it. The token exists in exactly two places: the provider dashboard and a `chmod 600` file. ### The provisioning sequence The quickstart, as an agent runs it (every step links to its agent-lane block there): ``` clone template → gh repo create + push → bin/railsfast init --yes --name "My Project" → ssh-keygen → hcloud server create (cloud-config, deploy + builder) → firewall → Cloudflare A record + SSL-mode check → poll /etc/railsfast-setup.version readiness → fill deploy.yml (IPs, host, key) → commit → kamal accessory boot all → kamal registry setup → kamal deploy → assert /healthcheck ``` Server initialization (~5 min) overlaps with DNS and `deploy.yml` work, so wall-clock from sentence to live site is dominated by the first Docker build. ## Day one: configuring every service After the site is live, `bin/railsfast doctor --json` is the agent's worklist: every remaining finding, machine-readable, each carrying its own fix. The [[getting-started/4-configuration|configuration page]] carries a terminal/agent lane for each service. The orchestration layer: **Ask first, in one batch** (like day zero — again with recommended defaults): which AWS profile and SES region (`aws sts get-caller-identity` tells you what's already authenticated); the mailbox addresses; which services to skip for now (Stripe needs KYC on fresh accounts — a fine skip for a first pass); and the one extra token (Cloudflare **account**-scoped, R2 Edit + Turnstile Edit — distinct from day zero's zone-scoped DNS token). **Then verify everything empirically** — configured ≠ working. The agent's post-configuration assertions, all validated lanes: - Email: identity status via `aws sesv2 get-email-identity`, then a real message from production ([[guides/email-deliverability|the smoke test]]) — the human confirms SPF/DKIM/DMARC all `PASS` in the received headers. - Storage: an Active Storage round trip from production (`upload → exist? → delete` via `bin/kamal app exec --reuse 'bin/rails runner ...'`). - Turnstile: the widget script and sitekey render on `/users/sign_up`. - Backups: trigger one manually and list the bucket ([[getting-started/4-configuration|verify-your-backups lane]]). - The gate: `bin/railsfast doctor` exits clean apart from deliberate skips, and `/healthcheck` stays green after the redeploy. ## Day two: something to sell Credentials make payments *possible* — an actual sellable product needs three more things, in order: 1. **Plans in code**: define them in `config/initializers/pricing_plans.rb` (pricing, limits, features, marketing bullets) — pure Ruby, fully agent-editable. 2. **Products and Prices in Stripe**: one Product per plan plus its recurring Price(s) — fully agent-executable under the [[guides/stripe-organizations|permission list]] (sandbox first with the CLI, live with the org key — commands in that guide). Wire the returned `price_…` IDs into `config/railsfast/railsfast.yml` under `stripe.plans`, and switch each plan from the hardcoded `price` to the `stripe_price` line the initializer already stubs out. 3. **The human gates**: finishing Stripe account activation (identity verification — only a human can attest to that), and the branding pass (logo, OG image, favicon — `doctor`'s "optional" list). A good agent flow treats this as its own conversation: propose plan names, prices and limits from what it knows about the product, get the human's yes, then create everything and wire it up. ## Day three: operations on (mostly) autopilot Once the app is live, configured, and selling, the operational loop is: **allgood** (`/healthcheck` — the sensor) → **UptimeRobot** (the trigger — it has an API, so monitor creation is agent work) → **Telegram** (the channel — the template's admin notifications, one credential block) → **you or your agent** (the responder). Wire those once and incidents find you; nothing polls a dashboard. The standing cadence an agent can run on your behalf: - **Weekly fleet sweep**: `bin/railsfast doctor --json` + a `/healthcheck` probe + a skim of `bin/kamal logs` across every app — report anything actionable, touch nothing without a human yes. - **Restore drill, once per app** (and after any backup-pipeline change): the [[guides/restoring-backups|runbook]] against a scratch database. A backup that's never been restored is hope, not disaster recovery. - **Template updates**: check `bin/railsfast status` for drift; when a release ships, run the [[guides/updating|updating protocol]] on a branch and hand the human a green-CI PR. - **Server maintenance**: security patches auto-apply, but kernel updates need a reboot — when `ssh` greets you with `*** System restart required ***`, schedule one (containers come back on their own). Re-run the [[guides/server-setup-script|verify script]] quarterly. - **Know the escape hatch before you need it**: `bin/kamal rollback` exists and Kamal retains the last releases for exactly this — practice it once. What stays human on day three: acknowledging a real incident, anything account-destructive, and spend changes. Everything else above is safe, reversible agent work. ## The complete handoff manifest Everything a human hands over, in total, for a **fully hands-off** clone-to-configured-production run — each one a "create while logged in" step delivered via the secret handoff pattern above: | # | Handoff | Where | Scope | |---|---|---|---| | 1 | Hetzner API token | Console → project → Security → API tokens | Read & Write | | 2 | Cloudflare **zone** token | profile → API tokens | Zone: DNS Edit, Zone Read, Zone Settings Read (+ Edit only if the SSL mode may need changing) | | 3 | Cloudflare **account** token | profile → API tokens | Account: Workers R2 Storage Edit + Turnstile Edit | | 4 | AWS CLI auth | `aws configure` (usually already done) | a profile able to create IAM users/policies — the agent then mints per-project scoped users itself | | 5 | GitHub CLI auth | `gh auth login` (usually already done) | repo scope | | 6 | Stripe login | `stripe login` (browser, once per account) | yields all dev credentials | | 7 | Stripe restricted key | Dashboard → API keys → restricted key | the [[guides/stripe-organizations|exact permission list]] — the only *per-project* production key a human ever mints | That's the whole surface. Items 4–5 are usually already true on a dev machine; on a fresh project under an existing setup, the marginal human cost is three tokens, one login, and one restricted key — a few minutes, total. Deliberately **not** on the list: anything account-destructive (deleting repos, closing accounts). Keep those human. ## The loop your agent should run ``` small change → bin/ci → commit → bin/kamal deploy → check /healthcheck ``` That's the whole workflow. The [[getting-started/5-develop|develop guide]] has the human version of the same story. > [!TIP] > Console steps in these docs (Hetzner, Cloudflare, AWS) increasingly carry CLI equivalents in collapsible blocks — those are the agent lane. Where a step truly needs a human (buying a domain, an OTP), the docs say so explicitly. --- ## RailsFast Native Quickstart This is the fastest path from "I have RailsFast Native access" to a real Rails server running inside both native shells. RailsFast Native has three repos: - `railsfast-base` — the Rails app and native server contract - `railsfast-android` — the Android shell - `railsfast-ios` — the iOS shell The native apps are not standalone demos. They render your Rails app, start at `/native/entry`, load path configuration from Rails, and use the same Devise session cookie as the web app. ## 1. Clone The Repos With The Right Remotes Start with the normal RailsFast Base quickstart. The important remote convention is: keep the template remote named `railsfast`, and put your private downstream repo on `origin`. ```bash git clone --origin railsfast https://github.com/railsfast/railsfast-base.git myapp cd myapp git remote add origin https://github.com/YOUR-USERNAME/myapp.git git push -u origin main ``` Do the same for the native repos: ```bash git clone --origin railsfast https://github.com/railsfast/railsfast-android.git myapp-android cd myapp-android git remote add origin https://github.com/YOUR-USERNAME/myapp-android.git git push -u origin main git clone --origin railsfast https://github.com/railsfast/railsfast-ios.git myapp-ios cd myapp-ios git remote add origin https://github.com/YOUR-USERNAME/myapp-ios.git git push -u origin main ``` > [!IMPORTANT] > Keep the downstream repos private. RailsFast template repos must not be published publicly. This remote layout is intentional. Later, you can fetch template updates from `railsfast` and push your product work to `origin` without confusing the two histories. ## 2. Get Rails Running First Finish the RailsFast Base setup before debugging native: ```bash cd myapp bin/railsfast init bin/dev ``` Confirm these URLs exist while the server is running: ```bash curl -I http://localhost:3000/native/entry curl -I http://localhost:3000/native/configurations/ios/v1.json curl -I http://localhost:3000/native/configurations/android/v1.json curl -I http://localhost:3000/.well-known/railsfast.json ``` If `/native/entry` is broken, fix Rails first. The native shells only present the server contract; they cannot repair missing routes, bad auth redirects, or invalid path configuration. ## 3. Run Android Android emulator networking is different from your Mac's browser. The emulator reaches the Rails server on your Mac through `http://10.0.2.2:3000`, not `localhost`. ```bash cd myapp-android ./gradlew installDebug ``` To point at another server: ```bash ./gradlew installDebug -PrailsfastBaseUrl=https://app.example.com ``` Make the Android app yours with the rename script: ```bash bin/rename \ --package com.example.myapp \ --app-name "My App" \ --base-url https://app.example.com \ --commit ``` If you want the rename script to also update Rails' native Android package setting, pass the absolute path to your Rails config: ```bash bin/rename \ --package com.example.myapp \ --app-name "My App" \ --base-url https://app.example.com \ --railsfast-config /absolute/path/to/myapp/config/railsfast/railsfast.yml \ --commit ``` Use a real HTTPS domain before testing Android App Links. Local hosts can be opened with an explicit `adb` intent, but they cannot pass Android App Links verification. ## 4. Run iOS The iOS project is generated from `project.yml` with XcodeGen. After every `project.yml` identity change, regenerate before opening Xcode. ```bash cd myapp-ios brew install xcodegen xcodegen generate open RailsFast.xcodeproj ``` For local Rails development, the simulator can use `http://localhost:3000`; `NSAllowsLocalNetworking` is already set. For staging or production, edit `RAILSFAST_BASE_URL` in `project.yml`, then regenerate. Run the test suite on an iOS 26 simulator when you touch shell behavior: ```bash xcodebuild test \ -project RailsFast.xcodeproj \ -scheme RailsFast \ -destination 'platform=iOS Simulator,name=iPhone 17 Pro,OS=26.3.1' \ CODE_SIGNING_ALLOWED=NO ``` > [!TIP] > If Xcode reports multiple or missing simulators for a `name + OS` destination, use a concrete simulator UDID instead: `xcrun simctl list devices available`, then pass `-destination "platform=iOS Simulator,id="`. RailsFast iOS CI does this because hosted runner images change their pre-created simulator list over time. Make the iOS app yours in `project.yml`: - app display name - bundle identifier - `RAILSFAST_BASE_URL` - `RAILSFAST_ASSOCIATED_DOMAIN` - custom URL scheme - Apple Team ID/signing settings when you are ready for devices and TestFlight Before a TestFlight/App Store upload, bump the source-controlled iOS version settings from the iOS repo root: ```bash ruby scripts/ios_release_version ``` Use `ruby scripts/ios_release_version --bump none` for another TestFlight build of the same public version. The script updates `MARKETING_VERSION` and `CURRENT_PROJECT_VERSION` in `project.yml`, runs `xcodegen generate`, and leaves the generated Xcode project ready to commit. Do not edit Version or Build only in Xcode on the signing Mac; XcodeGen will overwrite those Xcode-only edits the next time the project is regenerated. Keep the user-agent layering intact. Your app name is app-specific; the `RailsFast Native iOS` / `RailsFast Native Android` segments are template identity and Rails uses them for native detection. Before shipping either shell, generate the installed launcher icons and launch surfaces from the same canonical brand artwork. Follow [[native/6-icons-and-launch-screens|App Icons and Launch Screens]] for the exact iOS asset-catalog, Android adaptive-icon, cache-reset, and cold-launch verification workflow. ## 5. Configure Production Links Universal Links and Android App Links require both sides: - the native binary claims the domain - Rails serves the matching well-known file over real HTTPS RailsFast Base serves: - `/.well-known/assetlinks.json` - `/.well-known/apple-app-site-association` - `/apple-app-site-association` The endpoints intentionally fail closed until you configure real app identity. Android returns `[]` until a package name and SHA-256 fingerprint exist. iOS returns a valid non-claiming AASA payload until Team ID, bundle/app IDs, and path claims exist. For iOS, configure the app IDs and path list with `IOS_APP_LINK_TEAM_ID`, `IOS_APP_LINK_BUNDLE_ID`, `IOS_APP_LINK_APP_IDS`, `IOS_APP_LINK_PATHS`, or `native.ios.app_link_paths` in `config/railsfast/railsfast.yml`. For Android, configure the package name and release signing fingerprint before expecting Chrome-to-app auto-open to work. Restart Rails after editing `config/railsfast/railsfast.yml`; it is app configuration, not hot-reloaded page content. > [!CAUTION] > Do not claim `/*` by default. Claim only the paths the native app should open. Product, marketing, legal, support, billing, and auth URLs may need different behavior. ## 6. Verify The Real Flows Before you call the native setup done, test these on both platforms: - cold launch lands on `/native/entry` - signed-out welcome screen opens - sign-in and sign-up open as native sheets/modals - bad credentials stay on the form and show the right validation message - successful auth goes through `/native/handoff` and closes the auth shell - logout returns to the signed-out shell - onboarding users stay outside tabs - signed-in users reach the tab shell - `/dashboard`, `/native/me`, `/settings`, `/organizations`, `/memberships`, `/invitations`, and `/billing` stay in their expected tab families - native toasts render for bridge-capable clients, while HTML flash remains the fallback - a native JSON `401` returns to auth instead of leaving the shell stuck - Android works with at least one emulator - iOS works on iOS 18 and iOS 26 if your deployment range spans both visual systems ## Gotchas To Keep In Mind - Rails owns product rules. Native owns container behavior. - Path configuration is the routing source of truth; bundled native JSON files are fallbacks, not a second product contract. - Query strings do not decide tab ownership. Route-family matching is path-based. - iOS and Android share `settings.shell`, but their `rules[]` arrays are platform-specific. - Auth success goes to `/native/handoff`, not directly to `/dashboard` and not back through the auth sheet. - Bridge markup is gated by advertised bridge-component support in the user agent, not by platform name. - Cookie sharing solves authentication continuity, not CSRF. Native writes need an explicit server-side CSRF policy. - Android's emulator host is `10.0.2.2`; iOS Simulator can use `localhost`. - iOS app identity is generated from `project.yml`; run `xcodegen generate` after changing it. - iOS release version/build numbers are generated from `project.yml`; use `ruby scripts/ios_release_version` instead of editing Xcode on the signing Mac. - Native billing is intentionally not enabled by default. Do not expose web pricing or checkout inside the native apps until you have a real App Store / Play billing strategy. ## References - Hotwire Native path configuration: https://native.hotwired.dev/overview/path-configuration - Hotwire Native bridge components: https://native.hotwired.dev/overview/bridge-components - Hotwire Native iOS configuration: https://native.hotwired.dev/ios/configuration - Android emulator networking: https://developer.android.com/studio/run/emulator-networking-address - Android App Links verification: https://developer.android.com/training/app-links/verify-android-applinks - Apple Associated Domains: https://developer.apple.com/documentation/xcode/supporting-associated-domains - Apple Universal Links debugging: https://developer.apple.com/documentation/technotes/tn3155-debugging-universal-links - Apple Info.plist version keys: https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html - Apple `agvtool` versioning reference: https://developer.apple.com/library/archive/qa/qa1827/_index.html - App Store Connect API builds: https://developer.apple.com/documentation/appstoreconnectapi/builds - fastlane latest TestFlight build number: https://docs.fastlane.tools/actions/latest_testflight_build_number/ - XcodeGen project spec: https://yonaskolb.github.io/XcodeGen/Docs/ProjectSpec.html - GitHub macOS runner image matrix: https://github.com/actions/runner-images/blob/main/images/macos/macos-15-Readme.md ## Next - [[native/1-overview|Native Overview]] for the mental model. - [[native/2-railsfast-base|RailsFast Base for Native]] for the server contract. - [[native/3-android|RailsFast Android]] for Android internals. - [[native/4-ios|RailsFast iOS]] for iOS internals. - [[native/5-customization|Customization Workflow]] for deciding where changes belong. - [[native/6-icons-and-launch-screens|App Icons and Launch Screens]] for native branding assets and launch verification. --- ## RailsFast Native Overview RailsFast Native is the Hotwire Native layer that sits on top of the normal RailsFast Base template. If you want the shortest setup path, start with [[native/0-quickstart|RailsFast Native Quickstart]]. This page gives you the mental model so the rest of the native docs make sense fast. > [!INFO] > RailsFast Native is an optional addon layer. RailsFast Base remains fully useful as a web-only SaaS template on its own, and you can ship the web app to production long before you ever touch the mobile shells. The Android and iOS starters share the same Rails contract and differ only where the platform containers genuinely need to. > > **Building a web-only product?** There is nothing to remove or disable. The native surface in RailsFast Base (the `native/*` routes, path-configuration endpoints, and bridge markup) is inert for regular browsers — every native behavior is gated on `hotwire_native_app?`, browsers never see native markup (this is covered by the template's test suite), and no native-only gems are involved. Web-only apps like [LicenseSeat](https://licenseseat.com) run the exact same template in production. The most important thing to understand from the beginning is that this is **not** three equal apps. The actual shape is: - `railsfast-base`: the product brain - `railsfast-android`: a thin Android shell around the Rails app - `railsfast-ios`: a thin iOS shell around the Rails app Rails stays the source of truth for your product: - authentication and session - routes - HTML screens - onboarding rules - tab ownership rules - billing policy - path configuration The native repos mostly own: - the app container - native tabs - native sheets and modals - bridge components - platform-specific polish That split is the whole point of Hotwire Native. Rails keeps owning the product. The native repos provide the mobile app shell. ## The Main Mental Model If you bought RailsFast Base plus the native addons, hold this one idea in your head: > RailsFast Base decides what your app means; Android and iOS decide how that meaning is presented natively. A few concrete examples of what that buys you: - Rails decides that `/dashboard` belongs to the `home` tab. - Rails decides that `/settings`, `/organizations/**`, `/memberships/**`, `/invitations/**`, and `/billing/**` all belong to the `settings` tab — the whole account/workspace route family stays in one navigator. - Rails decides that the web pricing flow stays out of the native app for now (more on that in the companion-app note below). - Android decides how the bottom tab bar works. - Android decides which fragment or sheet class presents a given route. So when you ask yourself "where should I change this?", start with one question: > Is this a **product rule** or a **native container detail**? If it is a product rule, it belongs in RailsFast Base. If it is a native container detail, it belongs in the Android or iOS repo. ## The Three Shell States The spine of the whole model is a tiny three-value vocabulary. Rails publishes exactly one **shell state** for the current page, and both native apps decode it identically: - `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 (via `native_shell_state_for`), and native reacts. The default predicate is `User#native_onboarding?` — a user with no organization yet is still in setup. That predicate is intentionally small: it is the RailsFast default, not a universal truth, and it is meant to be evolved per app. > [!IMPORTANT] > The `auth`/`onboarding`-vs-`tabs` split is a **RailsFast addition**, not stock Hotwire Native. Vanilla Hotwire Native gives you a single tab-bar root with no separate auth shell — the official Hotwire Native iOS demo uses one `HotwireTabBarController` and nothing else. RailsFast layers the auth/onboarding shell on top so signing in and setting up don't feel trapped inside the main app tabs. If you already know Hotwire Native, this is the piece you didn't have before. These three states map onto **two native shells**: - a signed-out / onboarding shell (`auth` and `onboarding`) - a signed-in tab shell (`tabs`) That split is deliberate — it's the Evernote / Linear-style pattern where authentication and setup live outside the main tab bar. ## The Default Flow Here is the default RailsFast Native journey, end to end: 1. The app cold-launches into `/native/entry`. 2. Rails decides the page's shell state and the next canonical web URL. 3. Signed-out users land on the web welcome screen (`/native/auth/welcome`), still inside the `auth` shell. 4. Sign up and sign in open as sheets on top of that screen. 5. On auth success, the form posts and redirects to `/native/handoff`, which hands the user into the right shell. 6. Signed-in users who still need setup stay in `onboarding` (no tabs). 7. Signed-in users who are "inside the app" move into the native `tabs` shell. > [!NOTE] > `/native/entry` and `/native/handoff` are two different routes on purpose. Cold launch and sign-out always go through `/native/entry`; **auth success goes to `/native/handoff`**. Both render a deliberately tiny handoff document whose only job is to publish the shell bridge so the native side can swap to the correct root — they don't render real content, because real content would flash inside the wrong container. ## What Lives Where ### RailsFast Base (the product brain) Rails owns everything that should be remotely changeable without shipping a new mobile binary: - native bootstrap routes (`/native/entry`, `/native/handoff`, `/native/auth/welcome`) - the path configuration endpoints under `/native/configurations/...` - the native welcome screen (web HTML) - the three shell states (`auth`, `onboarding`, `tabs`) - auth, session persistence, and post-auth redirect behavior - which tab owns which route family - which routes should be modal, an auth sheet, clear-all, or stay in onboarding - the bridge markup for shell transitions, toasts, and menus - optional native-push bridge markup and token registration endpoints - the verified app-link files (`assetlinks.json`, `apple-app-site-association`) > [!TIP] > Path configuration is the single source of truth for routing, served per-platform. Tab policy lives in its `settings.shell` block — the default tab, which tabs are visible, each tab's canonical start path, and the route-family regexes. Because this is served by Rails, **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. That's a big reason this architecture is worth it. ### RailsFast Android (a thin shell) Android owns container details only: - the Hotwire `Application` bootstrap - the launcher activity and the signed-out activity - the signed-in activity with native bottom tabs - fragment and bridge-component registration - Android-only polish like sheet sizing and transition behavior ### RailsFast iOS (a thin shell) iOS consumes the same Rails contract and the same shell semantics — the same `/native/entry`, the same path configuration, the same three shell states, the same canonical URLs, the same tab route-family ownership, the same bridge contracts. Only the iOS container details differ. ## Three Facts That Orient Everything A few non-obvious facts make the rest of the docs click. Read these once now. **Auth is the same Devise cookie, not a mobile API.** Native uses the *same* Devise session as the web app — no JWT, no separate mobile auth API. For native requests RailsFast auto-enables Devise rememberable (no visible checkbox) so app users stay signed in across relaunches; web keeps remember-me as an explicit opt-in. The native apps are Rails-driven, not API-clients-first. > [!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. Native write endpoints need an explicit CSRF policy — the blessed pattern is `skip_forgery_protection` scoped to JSON for authenticated, native-only endpoints. See [[native/2-railsfast-base|RailsFast Base for Native]] before you wire up any native write. **Bridge output is gated by component support, not by platform.** This is the most important bridge rule: > Gate bridge output by component support, not by platform. Hotwire Native appends a `bridge-components: [...]` segment to the user agent once the native app registers its components. Rails reads that list and only emits a bridge tag if the component is advertised. If both iOS and Android register `toast`, Rails sends both the same payload — you never branch on `iOS` vs `Android` to decide whether to render a bridge. **Prefer the same HTML tree plus CSS hiding over native-only templates.** RailsFast reuses the web HTML for native instead of forking it: > Prefer the same HTML tree plus CSS hiding over separate native-only templates. Layouts publish native-aware markers (`html[data-hotwire-native]`, the `hotwire-native:` / `not-hotwire-native:` Tailwind variants, the `.hide@native` utility, and the `railsfast-native-shell` / `railsfast-native-authenticated` meta tags) so one HTML tree serves both web and native. This keeps the two surfaces aligned and minimizes divergent UI. ## What To Be Careful With The native layer is opinionated for a reason. The RailsFast way is: let Rails own the product, keep the shells thin, and ship. With that in mind, a short do-not list: - Do **not** invent a parallel mobile route universe when a normal Rails route already works. - Do **not** move product rules into Kotlin or Swift just because you *can* — keep them in Base where they can change without a binary release. - Do **not** assume path configuration can build your whole app shell for you. It transports app-defined `settings`, but your native binary still has to know how to render the supported tab set. - Do **not** think of the native apps as API clients first. They are mostly Rails-driven. - Do **not** branch bridge output on platform — branch on advertised component support. - Do **not** expose the web pricing flow in native. By default RailsFast treats native as a **companion app**: `/pricing`, `/subscribe`, and `/billing` redirect back into the app shell for native requests (signed-out and onboarding users to `/native/entry`, signed-in users to `/settings`), and pending web checkout intent is discarded. Don't half-enable native billing by accident — turn it on only with a deliberate App Store / Play billing strategy. ## Where To Go Next You have the mental model. Now go build: - [[native/0-quickstart|RailsFast Native Quickstart]] — the practical clone, configure, run, and verify sequence across Base, Android, and iOS. - [[native/2-railsfast-base|RailsFast Base for Native]] — the server contract: native routes, path configuration, shell semantics, bridge contracts, auth, and the CSRF policy you'll need for writes. Read this next. - [[native/3-android|RailsFast Android]] — the thin Android shell: activities, tabs, fragments, and bridge components. - [[native/4-ios|RailsFast iOS]] — the thin iOS shell: the two-shell `UIKit` root swap, tab catalog, and bridge components. - [[native/5-customization|Customization Workflow]] — the practical "I want to change X, where does it go?" workflow across all three repos. - [[native/6-icons-and-launch-screens|App Icons and Launch Screens]] — the reproducible iOS/Android icon, splash, cache-reset, and installed-device verification workflow. - [[native/7-notifications|Notifications]] — the opt-in push notification contract across Rails, iOS APNs, and Android FCM. New to RailsFast itself? Start with the [[getting-started/2-quickstart|Quickstart]] and ship the web app to production first — the native layer is something you add once the product is real. Or head back to the [[index|docs home page]]. --- ## 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/1-overview|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: ```ruby # 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`: ```ruby # 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](#5-shell-states-and-the-entryhandoff-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)`: ```erb <%# app/views/layouts/html_head/_native_shell_meta.html.erb %> ``` 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: ```json { "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: ```ruby # app/controllers/application_controller.rb def native_shell_state_for(user) return "auth" unless user user.native_onboarding? ? "onboarding" : "tabs" end ``` ```ruby # 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 `` 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: ```erb <%# app/views/native/entries/show.html.erb %> <% title "" %> <% description "" %> ``` 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: ```json { "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: ```erb <%# 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) ```ruby # 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 ```ruby # 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: ```json { "user": { "email": "...", "confirmed": true, "created_at": "" }, "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. ## 9. Verified App Links 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: ```json { "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: ```bash IOS_APP_LINK_TEAM_ID=ABCDE12345 IOS_APP_LINK_BUNDLE_ID=com.example.ios ``` or: ```bash IOS_APP_LINK_APP_IDS=ABCDE12345.com.example.ios,ABCDE12345.com.example.ios.beta ``` You can override the claimed path set without touching YAML: ```bash 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: - Apple Associated Domains: https://developer.apple.com/documentation/xcode/supporting-associated-domains - Apple Universal Links debugging: https://developer.apple.com/documentation/technotes/tn3155-debugging-universal-links - Android App Links verification: https://developer.android.com/training/app-links/verify-android-applinks ## 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. ## Related Docs - [[native/1-overview|Native Overview]] — the mental model; read it first. - [[native/3-android|RailsFast Android]] — the thin Android shell that consumes this contract. - [[native/4-ios|RailsFast iOS]] — the thin iOS shell that consumes this contract. - [[native/5-customization|Customization Workflow]] — the practical "I want to change X, where does it go?" workflow across all three repos. - [[native/6-icons-and-launch-screens|App Icons and Launch Screens]] — native icon and launch asset generation. - [[features/1-auth|User Authentication & Login]] — the Devise setup the native auth flow builds on. --- ## RailsFast Native - Android RailsFast Android is a thin Hotwire Native Android shell around your RailsFast Base app. This page gets you from `git clone` to a running app pointed at your Rails server, then walks the two-activity architecture, native tabs, bridge components, base URL config, deep links, backup rules, and the rename script you run to make the project yours. > [!INFO] > Android is a **thin shell**, not a second product. Its job is to bootstrap Hotwire correctly, present the right native shell, keep Android-specific behavior polished, and consume the Rails server contract cleanly. Rails stays the product brain. See [[native/1-overview|Native Overview]] for the full split. ## Clone, Point, Run You need three things before the app does anything useful: 1. A running RailsFast Base server (your "product brain"). The shell is not standalone — it renders Rails screens. 2. Android Studio with a recent toolchain. The project uses **AGP 9.1.0** and **Kotlin 2.2.10**, and `compileSdk` uses the newer `release(36) { minorApiLevel = 1 }` DSL. An older Android Studio may not parse the build files, so update Android Studio first if Gradle sync fails. 3. An emulator or device on **minSdk 28** or higher (`targetSdk` / `compileSdk` are 36, Java 11). > [!TIP] > The Android emulator reaches a Rails server running on your Mac through the host alias `http://10.0.2.2:3000`, **not** `localhost`. That is the template default base URL, so a stock clone pointed at a local `bin/dev` server just works on the emulator. Once your Rails server is up, install and run the debug build: ```bash # from the railsfast-android repo root ./gradlew installDebug ``` Or just press Run in Android Studio. The app launches into `/native/entry`, Rails decides where to send you, and the shell takes it from there. To point the build at a different host (a remote dev box, staging, production), pass the base URL as a Gradle property: ```bash ./gradlew installDebug -PrailsfastBaseUrl=https://app.example.com ``` > [!IMPORTANT] > Do not commit a downstream production host as the template default. Keep `gradle.properties` on the emulator alias and override per-build with `-PrailsfastBaseUrl=...`, or set your real domain once you run [the rename script](#make-the-project-yours-binrename). ### Version Pin Hotwire Native Android is pinned to **1.2.8** via the `hotwireNative` version ref in `gradle/libs.versions.toml`. Both `dev.hotwire:core` and `dev.hotwire:navigation-fragments` share that single ref, so they always move together. ```toml # gradle/libs.versions.toml hotwireNative = "1.2.8" ``` > [!WARNING] > The Hotwire Native version is cross-platform-coupled. Keep Android in step with the version [[native/4-ios|RailsFast iOS]] is pinned to so both shells consume the same framework behavior. Bumping it can shift bridge and bottom-navigation APIs, so re-test the shell after any change. ## The Two-Activity Architecture RailsFast Android uses **two activities**, both `HotwireActivity` subclasses that implement `NativeShellHost`: - `AuthActivity` — the signed-out / standalone shell. A single navigator named `standalone`, hosting `/native/entry`, the welcome screen, auth sheets, and onboarding that must run outside tabs. - `MainActivity` — the signed-in tab shell. Bottom navigation with one navigator host per tab. This maps directly to the [[native/2-railsfast-base|shell states]] Rails publishes: `auth` and `onboarding` live in `AuthActivity`; `tabs` lives in `MainActivity`. > The two-shell split keeps auth and setup from feeling trapped inside the main app tabs. When Rails signals a shell-state change, the current activity starts the other one and `finish()`es itself, with activity transition animations suppressed (`ActivityTransitions.kt`) so the swap reads as a **root replacement**, not a screen navigation. `AuthActivity` is the only entrypoint. It is `exported=true`, the `MAIN`/`LAUNCHER` activity, and the `VIEW`/`BROWSABLE`/`DEFAULT` deep-link target. `MainActivity` is `exported=false` with no intent filters — it is only ever reached via the `AuthActivity` handoff or an explicit intent. ### Main Files App bootstrap: - `app/src/main/java/com/railsfast/android/RailsFastApplication.kt` — all Hotwire setup happens once in `onCreate` - `app/src/main/java/com/railsfast/android/AppConfiguration.kt` — base URL, derived URLs, launch-location canonicalization - `app/src/main/assets/json/path-configuration.json` — bundled fallback path config Signed-out shell: - `app/src/main/java/com/railsfast/android/AuthActivity.kt` - `app/src/main/res/layout/activity_auth.xml` - `app/src/main/java/com/railsfast/android/AuthSheetFragment.kt` Signed-in tab shell: - `app/src/main/java/com/railsfast/android/MainActivity.kt` - `app/src/main/res/layout/activity_main.xml` - `app/src/main/java/com/railsfast/android/MainTabs.kt` — the compile-time tab catalog and route-family matching - `app/src/main/java/com/railsfast/android/TabShellRouteDecisionHandler.kt` — the cross-tab router - `app/src/main/java/com/railsfast/android/NativeShellConfiguration.kt` — parses `settings.shell` from path config Shell, fragments, bridge, and infrastructure: - `app/src/main/java/com/railsfast/android/WebFragment.kt` - `app/src/main/java/com/railsfast/android/NativeShellHost.kt`, `NativeShellState.kt`, `ActivityTransitions.kt` - `app/src/main/java/com/railsfast/android/NativeHttpClient.kt` — cookie-forwarding GET client for native screens - `app/src/main/java/com/railsfast/android/NativeTabSelectionAware.kt` — defers work on eagerly-created tab roots - `app/src/main/java/com/railsfast/android/bridge/` — `NativeShellComponent`, `MenuComponent`, `OverflowMenuComponent`, `ToastComponent` - `app/src/main/java/com/railsfast/android/features/me/` — the Compose-native Account screen ## What `RailsFastApplication` Sets Up All Hotwire configuration is centralized in `RailsFastApplication.onCreate()` via `configureHotwire()`, following the official Android demo shape rather than per-activity setup. It wires up: - **Path configuration** — both the local asset (`json/path-configuration.json`) and the remote URL (`$baseUrl/native/configurations/android/v1.json`). - **Debug flags** — `debugLoggingEnabled` and `webViewDebuggingEnabled` track `BuildConfig.DEBUG`. - **The user-agent prefix** (see below). - **The default fragment destination** — `WebFragment`. - **Route decision handlers** (see below). - **Fragment destinations** — `WebFragment`, `AuthSheetFragment`, `NativeMeFragment`. - **Bridge components** — `native-shell`, `menu`, `overflow-menu`, `toast`. Keeping both local and remote path configuration is intentional. The local file is your safe fallback when the remote contract is unavailable, stale, or has not loaded yet. > [!NOTE] > Backup exclusion rules are **not** configured here — they live in the manifest and `res/xml/`. See [Backup Rules](#backup-rules) below. ## Tabs Are Native RailsFast Android uses the stock Hotwire Native bottom tab system (`HotwireBottomNavigationController`, Hotwire 1.2+). Tabs are **not** bridge components — the tab bar is a native shell concern, while bridge components are for progressive native enhancements and app-shell signals. Hotwire ships no built-in authed-vs-signed-out shell, so the two-activity pattern and the tab catalog are the app-layer contract that fills that gap. ### How Tab Ownership Works This is one of the most important concepts in the Android app. > Tabs are not owned only by their start URL. They are owned by **route families**. The default tabs and the route families they own: - `home` — `/dashboard` family (`^/dashboard(?:/.*)?$`), starts at `/dashboard` - `account` — `/native/me` family (`^/native/me(?:/.*)?$`), starts at `/native/me` (the native Compose screen) - `settings` — `/settings`, `/organizations`, `/memberships`, `/invitations`, and `/billing` families, starts at `/settings` Each family is a regex matched against the URL **path only**. Query strings are stripped first, so `/settings?return_to=/dashboard` still belongs to the `settings` tab. The settings tab deliberately owns the whole `^/billing(?:/.*)?$` family (and the org/membership/invitation families) so those pages stay in the Settings navigator instead of flickering between tabs. > [!IMPORTANT] > Tab ownership is the same SSOT on every platform. `MainTabs` merges the server's `settings.shell` block (from path config) over the hardcoded supported-tab defaults, and the values must stay identical to what Rails serves at `/native/configurations/android/v1.json` and to the iOS catalog. If they drift, cross-tab routing silently sends users to the wrong tab. If you only map tabs by their root URL, navigation feels broken fast. `MainTabs.Catalog.tabFor(location)` returns `null` when no route pattern claims the URL. That is a meaningful value: it means **tab-agnostic** — push the URL onto the tab the user is currently on rather than forcing a jump to the default tab. The stock `AppNavigationRouteDecisionHandler` does that in-tab push. > [!WARNING] > `tabFor()` returning `null` means "stack on the current tab," NOT "use the default tab." Only **cold launch** falls back to `defaultTab()` — when the app starts from an unclaimed deep link there is no current tab yet, so `MainActivity` starts on the configured default and routes the URL there once the navigator is ready. ### The Route Decision Handler Order `Hotwire.registerRouteDecisionHandlers(...)` **replaces** the entire handler list — it does not append. The Router stops at the first matching handler, so order is load-bearing: ```kotlin // app/src/main/java/com/railsfast/android/RailsFastApplication.kt Hotwire.registerRouteDecisionHandlers( TabShellRouteDecisionHandler(), // custom — MUST be first AppNavigationRouteDecisionHandler(), // stock — normal in-app push BrowserTabRouteDecisionHandler(), // stock — external links in a Custom Tab SystemNavigationRouteDecisionHandler() // stock — system navigation ) ``` > [!WARNING] > Register `TabShellRouteDecisionHandler` **first** so cross-tab internal URLs switch tabs before the stock handler treats them as a normal push. And keep the three stock handlers registered after it — because the call replaces the whole list, dropping them breaks external links (Custom Tab) and same-tab navigation. `TabShellRouteDecisionHandler` only matches when the source navigator's tab differs from the tab that owns the target URL. On a match it routes cross-tab inside `MainActivity` and cancels the stock navigation; otherwise it falls through. > [!NOTE] > Android eagerly creates **all** bottom-tab roots up front, so hidden WebViews in background tabs can fire bridge messages or finish visits. The shell guards every shell-affecting path with `acceptsShellUpdate` / background-source checks, and native screens defer real work to `NativeTabSelectionAware.onTabSelected`. This is the single biggest Android-vs-iOS behavioral difference to keep in mind. ## Bridge Components `RailsFastApplication` registers four bridge components. Rails renders the matching hidden markup only when the component is advertised in the user agent (see [User Agent](#user-agent)), so these are gated by capability, not platform. ### `native-shell` `NativeShellComponent` listens for the `connect` event and reads `{state, handoff, location}`. State maps via `NativeShellState.from()` to `AUTH` / `ONBOARDING` / `TABS`, then the activity swaps shells. It ignores modal destinations **unless** `handoff=true` (the auth-success handoff document is the one modal allowed to reshape the root), ignores invisible fragments, and rejects updates from a navigator that is not the currently accepted one. This is the app-defined shell contract Hotwire does not provide out of the box. ### `toast` `ToastComponent` listens for `show` with the shared payload: ```json { "message": "Saved", "severity": "success" } ``` Platform mapping: - `success` (and unknown/legacy severities) → a short Android `Toast` - `error`, `warning`, `info` → a Material `Snackbar` anchored on the fragment's own view, with an OK dismiss action Non-success snackbars are `LENGTH_INDEFINITE` on purpose: auth and validation messages can appear while the keyboard or a sheet is up, and short durations are easy to miss. Snackbars anchor on the fragment view (never `android.R.id.content`) to stay visible above modals and sheets. The severity vocabulary (`success`/`error`/`warning`/`info`) mirrors the Rails `HotwireNativeHelper` flash normalization and the iOS toast component. ### `menu` `MenuComponent` listens for `display` with `{title, items: [{title, index}]}` and renders a Material `BottomSheetDialog`. On selection it replies `{selectedIndex: }` with the **server-provided** index — the web stays the source of truth for what each action does. RailsFast Base uses this for the session-actions menu (Sign out). ### `overflow-menu` `OverflowMenuComponent` listens for `connect` with `{label}`, makes the toolbar's overflow item (inflated from `R.menu.web`) visible with that label, and replies `connect` when tapped so the web page can trigger the menu. The web page decides whether the overflow affordance exists; native only presents it. > [!TIP] > The bridge contract — event names, payload schemas, reply schemas — is shared across web, Android, and iOS. If you add a bridge component, give it a Rails-rendered trigger, a web `BridgeComponent` controller in RailsFast Base, and a matching native component registered in `RailsFastApplication`. Keep the native side small. References: - [Hotwire Native Android bridge components](https://native.hotwired.dev/android/bridge-components) - [Android Snackbars](https://developer.android.com/develop/ui/views/notifications/snackbar) - [Material Snackbars](https://m2.material.io/components/snackbars) ## The Native Account Screen (Compose) The Account tab is the reference recipe for a fully native screen. Copy this split for any new native screen instead of putting networking or route parsing inside Compose: - `NativeMeFragment` (a `HotwireFragment`, also `NativeTabSelectionAware`) hosts a `ComposeView` and owns the Hotwire destination. - `NativeMeViewModel` owns loading / retry / unauthorized state and fetches off the Compose tree. - `NativeMeScreen` is the pure Compose UI. The view model builds a `.json` URL via `AppConfiguration.jsonLocation(location)` and fetches it through `NativeHttpClient.get(...)` on `Dispatchers.IO`. A `401` routes back to `/native/entry` (the session is gone, so converge on the auth shell). Because Android eagerly creates every tab root, the fetch is deferred to `NativeTabSelectionAware.onTabSelected` rather than firing while the tab is still hidden. The JSON shape Rails serves at `/native/me.json`: ```json { "user": { "email": "...", "confirmed": true, "created_at": "2026-05-28T00:00:00Z" }, "organization": { "name": "Acme" }, "account": { "plan_name": "Free", "credits": 0, "onboarding": false } } ``` ### Cookie Forwarding Is Manual on Android `NativeHttpClient` is the load-bearing auth-continuity detail for native screens: - It ships `get`, plus `postJson` / `patchJson` write helpers (verb-agnostic `execute()` with an optional per-call timeout). But cookie sync only solves **authentication, not CSRF** — a native write still needs a server-side CSRF policy before it will succeed (see [[native/5-customization#the-csrf-write-contract-read-this-before-you-post|the native write contract]]). Until you add that, treat reads as the safe default. - OkHttp does **not** reuse the WebView's cookies automatically (unlike WebView-backed screens), so the client manually copies the `Cookie` header from `CookieManager.getInstance().getCookie(location)` onto each request. ```kotlin // app/src/main/java/com/railsfast/android/NativeHttpClient.kt val cookieHeader = cookies.getCookie(location) cookieHeader?.let { requestBuilder.header("Cookie", it) } ``` > [!WARNING] > Any new native screen that needs an authenticated request must rely on this manual forwarding — OkHttp will not carry the Rails session by itself. And cookie forwarding solves **authentication, not CSRF**. Native writes still need an explicit server-side CSRF policy (the blessed pattern is `skip_forgery_protection` scoped to JSON for authenticated native-only endpoints). See [[native/2-railsfast-base|RailsFast Base for Native]]. ## Auth Sheets Sign up and sign in both route through a single Android rule — `hotwire://fragment/web/modal/auth-sheet` — handled by `AuthSheetFragment` (a `HotwireWebBottomSheetFragment`). > [!IMPORTANT] > Android ignores `modal_style` entirely. Where iOS uses `large` for signup and `medium` for signin, Android has **one combined auth-sheet rule** and always opens the sheet at `STATE_EXPANDED` with `skipCollapsed=true`. Auth forms plus a CAPTCHA / Turnstile widget are cramped in the collapsed peek state, so the sheet starts fully expanded. The signed-out welcome screen stays web-rendered underneath the sheet, so dismissing it can briefly repaint the underlying WebView. ## User Agent The Android template prefix is: ```kotlin // app/src/main/java/com/railsfast/android/RailsFastApplication.kt "${BuildConfig.APPLICATION_NAME} Android; RailsFast Native Android;" ``` Hotwire Native appends the framework identity and bridge component list after the prefix: ```text {AppName} Android; RailsFast Native Android; Hotwire Native Android; Turbo Native Android; bridge-components: [...] ``` Do not duplicate the framework strings in the app prefix. The prefix is layered so Rails can feature-detect at three levels: - `hotwire_native_app?` — any Hotwire app (framework level) - `railsfast_native_app?` — any RailsFast native app (template level, the `RailsFast Native Android` segment) - the app-specific `{AppName}` segment — your specific app > [!IMPORTANT] > Bridge output (toast, menu, overflow-menu) is gated by the advertised `bridge-components: [...]` segment, **not** by platform. Rails renders a bridge tag only when the component name appears in that list. If both Android and iOS register `toast`, Rails sends the same toast payload to both. This is the governing cross-platform rule, which is why the UA segment matters. ## Base URL and Build Identity Base URL resolution happens in `app/build.gradle.kts`: 1. The `-PrailsfastBaseUrl=...` Gradle property wins if present. 2. Otherwise `railsfastDefaultBaseUrl`, which derives from a `railsfast.yml` `domain` as `https://` (unless the domain is the placeholder `example.com`), and otherwise falls back to the `gradle.properties` value (`http://10.0.2.2:3000`). The resolved value is exposed as `BuildConfig.BASE_URL`. `AppConfiguration.baseUrl` strips the trailing slash, and from it derives `nativeEntryLocation` (`+ /native/entry`) and `androidPathConfigurationUrl` (`+ /native/configurations/android/v1.json`). > [!NOTE] > There are **no Gradle product flavors**. Build identity — app label, application ID/namespace, base URL, app-link host — comes entirely from `gradle.properties`, `-P` properties, the optional `RAILSFAST_CONFIG_PATH` import, and an optional `railsfast.yml`. Only `debug` and `release` build types exist (and `release` does not minify). > [!CAUTION] > The README and `bin/rename` show `http://10.0.2.2:3001` in their examples, but the committed `gradle.properties` default is `:3000`. Match the port to whatever your `bin/dev` Rails server actually listens on. ## Deep Links and App Links `AuthActivity` is both the launcher and the app-link entrypoint. Its `VIEW` intent filter uses Gradle `manifestPlaceholders` derived from the base URL (`deepLinkScheme`, `deepLinkHost`) and `android:autoVerify="${deepLinkAutoVerify}"`. `deepLinkAutoVerify` is computed in Gradle and is `true` **only** when the scheme is `https`, the host is not `localhost` / `127.0.0.1` / `10.0.2.2`, and the host is not a raw IPv4 address. > [!WARNING] > Local emulator hosts (`10.0.2.2`) still get a `VIEW` filter, so you can test deep links with an explicit `adb` intent — but they will **never** auto-open from Chrome, because they cannot pass Android App Links verification. Real browser-to-app auto-open needs a real `https` domain plus `/.well-known/assetlinks.json` served by your Rails app. RailsFast Base serves that file (and it fails closed with `[]` until you configure a package name and signing fingerprint). See [[native/2-railsfast-base|Verified App Links]]. `AppConfiguration.launchLocation` canonicalizes the incoming URL: host-less paths get a leading `/`, same-host URLs pass through, `localhost`/`127.0.0.1` are rewritten to the base host (so Hotwire does not treat dev hosts as external), and any other host is rejected as external. ## Backup Rules RailsFast Android keeps the WebView browser profile out of Android backup and device-transfer payloads. This is configured in the manifest and the `res/xml/` rule files, not in the Hotwire bootstrap: - `app/src/main/AndroidManifest.xml` — `allowBackup=true` with `fullBackupContent=@xml/backup_rules` and `dataExtractionRules=@xml/data_extraction_rules` - `app/src/main/res/xml/backup_rules.xml` — excludes `app_webview/` from the `root` domain (legacy full-backup) - `app/src/main/res/xml/data_extraction_rules.xml` — excludes `app_webview/` under both `` and `` (Android 12+) > [!IMPORTANT] > RailsFast uses server-owned sessions. Restoring another install's WebView cookie/cache/profile store could resurrect stale cookies or an incompatible browser profile, so the WebView data directory is deliberately kept device-local. Directory excludes are recursive. Reference: [Android Auto Backup include/exclude rules](https://developer.android.com/identity/data/autobackup#include-exclude-files). ## Make the Project Yours: `bin/rename` The template ships with the placeholder package `com.railsfast.android`. The code namespace stays on that placeholder until you rename the source tree. Run the rename script to make the project yours: ```bash bin/rename ``` It prompts for (or accepts as flags) `--package`, `--app-name`, `--base-url`, `--railsfast-config`, `--commit`, and `--yes`. It: - validates the package name against a Java-package regex - upserts `railsfastNamespace`, `railsfastApplicationId`, `railsfastApplicationName`, and `railsfastDefaultBaseUrl` in `gradle.properties` - physically moves the Kotlin package tree under `app/src/{main,test,androidTest}/java` - rewrites package references in all `.kt` / `.java` files - can optionally update `native.android.package_name` in a `railsfast.yml` - can `git`-commit the rename when `--commit` is passed > [!TIP] > The build never assumes a sibling `railsfastbase/` checkout. Importing shared identity from a `railsfast.yml` is opt-in only (`-PrailsfastConfigPath=/absolute/path/to/config/railsfast/railsfast.yml`), because RailsFast projects are typically cloned and renamed immediately during quickstart. ## Where To Customize Android ### Change the base URL Edit `gradle.properties` (`railsfastDefaultBaseUrl`) for the committed default, or pass `-PrailsfastBaseUrl=...` per build. `AppConfiguration.kt` derives everything else from `BuildConfig.BASE_URL`. ### Change the launcher icon and splash screen Use [[native/6-icons-and-launch-screens|App Icons and Launch Screens]] for the complete workflow. Android needs separate adaptive foreground/background layers, a monochrome layer for themed icons, generated legacy mipmaps, and intentional day/night splash colors. Do not reuse the already-masked iOS icon as the Android adaptive foreground. ### Change the supported native tabs Edit `MainTabs.kt`, `activity_main.xml` (the navigator hosts), and the local fallback `path-configuration.json` if your default shell policy changes. Use this when adding a tab, changing tab titles or icons, or adding navigator hosts. > [!IMPORTANT] > Tab route-family ownership is a **product rule**. If you change which routes a tab owns, change it in RailsFast Base path config first (so it can change without an app review), then keep the Android catalog defaults in sync as the offline fallback. ### Change shell handoff behavior Edit `AuthActivity.kt`, `MainActivity.kt`, and `ActivityTransitions.kt` — only if the app's shell architecture actually changes. ### Change Android-only presentation behavior Edit `WebFragment.kt` (toolbar visibility, server-driven `hide_navigation_bar`) and `AuthSheetFragment.kt` (sheet sizing). Use this for genuinely Android-only fragment behavior. ### Native title fonts Most of what you see on a tab root is your Rails HTML, so it already uses whatever fonts your web app loads — there is nothing to do on the Android side for that chrome. The surfaces that are *actually* native Android views, and therefore render in the system font, are narrow: the bottom-tab labels and the pushed-screen toolbar title. > [!NOTE] > Unlike iOS — where the navigation bar is always native UIKit chrome and the template ships a dedicated [[native/4-ios#change-native-navigation-title-fonts|font hook]] — RailsFast Android does **not** ship a font-theming hook, because tab roots render their branded header in the web view (HTML/CSS) and there is far less native chrome to style. The starter intentionally leaves these few native labels on the system font. If you do want to brand those native labels, it is plain Android theming, not a RailsFast mechanism: drop your `.ttf`/`.otf` into `res/font`, point your toolbar/tab `textAppearance` at it in your theme, and use a shared `Typography` for any Compose-native screens. Reach for this only if matching the native labels to your web type is worth carrying the font in the binary — for most apps the system font next to your web header looks perfectly native. ## When To Put Something In Android Instead of Rails > Put it in Android only when it is a true Android container concern. Belongs in Android: activity structure, fragments, bottom navigation, Android-only transitions, Android-only native UI. Belongs in RailsFast Base instead: route ownership rules, tab policy, product onboarding rules, billing/pricing policy. When in doubt, ask the question from the overview — is this a **product rule** or a **native container detail**? ## Related Docs - [[native/1-overview|Native Overview]] - [[native/2-railsfast-base|RailsFast Base for Native]] - [[native/4-ios|RailsFast iOS]] - [[native/5-customization|Customization Workflow]] - [[native/6-icons-and-launch-screens|App Icons and Launch Screens]] - [[native/7-notifications|Notifications]] --- ## RailsFast Native - iOS RailsFast iOS is a thin UIKit + Hotwire Native shell around your RailsFast Base app. Its job is not to rebuild the product in Swift. Its job is to: - bootstrap Hotwire Native correctly - present the right native shell - keep iOS-specific behavior polished - consume the Rails server contract cleanly - stay template-generic so downstream apps can brand and extend it The iOS starter follows the same Rails contract as [[native/3-android|RailsFast Android]]: Rails owns product semantics, and the native repo owns iOS container details. > Rails owns product semantics. The native repo owns iOS container details. > [!INFO] > If you are new to the native layer, read [[native/1-overview|Native Overview]] first for the big picture, then [[native/2-railsfast-base|RailsFast Base for Native]] for the server contract. This page is the iOS-specific deep dive. ## Run It On A Simulator Get the shell running before you change anything. You only need a running RailsFast server and a couple of tools. The Xcode project is generated by [XcodeGen](https://github.com/yonaskolb/XcodeGen) from `project.yml`. There is no committed `.xcodeproj` to fight with — you regenerate it from the YAML. ```bash # from the railsfast-ios repo root brew install xcodegen # one-time xcodegen generate # writes RailsFast.xcodeproj from project.yml open RailsFast.xcodeproj # open in Xcode ``` Then: 1. Start your RailsFast Base server so the shell has something to render (`bin/dev`, serving `http://localhost:3000`). 2. In Xcode, pick an iOS Simulator and press Run. 3. The app launches into `/native/entry` and Rails decides what to show next. The base URL defaults to `http://localhost:3000`. To point at a different server, edit `RAILSFAST_BASE_URL` in `project.yml` and run `xcodegen generate` again (or override it as an Xcode build setting). > [!TIP] > 👉 `NSAppTransportSecurity.NSAllowsLocalNetworking = true` is already set in `Info.plist` so the simulator can talk to your local server over plain HTTP. You do not need to configure anything for `localhost` dev. ## Toolchain And Version Pins A few pins are load-bearing. Know them before you upgrade anything. - **Hotwire Native iOS is pinned to exactly `1.3.0-beta`** via Swift Package Manager (`project.yml`). This is an exact tag, not a semver range. - Deployment target: **iOS 14.0**. Swift 5.9. `xcodeVersion: 16.0`. - Bundle id default `com.railsfast.ios`, device family `1,2` (iPhone + iPad). > [!WARNING] > The `1.3.0-beta` pin is not cosmetic. Several APIs in this template are version-specific: `hidesTabBarWhenPushed` is the **plural** spelling at this tag (main has used a different one), `HotwireTabBarController` exposes `activeNavigator` and `load(_:)` but not `navigator(for:)`, and `HotwireTab` has no `id`. Bumping the pin can silently break the shell. If you upgrade, re-audit `AppDelegate`, `RailsFastTabBarController`, and `Tabs.swift` against the new API. ## Release Versioning RailsFast iOS stores version/build settings in `project.yml`: ```yaml CURRENT_PROJECT_VERSION: 1 MARKETING_VERSION: "1.0.0" ``` `Info.plist` then derives: - `CFBundleShortVersionString = $(MARKETING_VERSION)` - `CFBundleVersion = $(CURRENT_PROJECT_VERSION)` Apple treats `CFBundleShortVersionString` as the user-facing release version and `CFBundleVersion` as the uploaded build iteration. The template keeps those values in `project.yml` because XcodeGen regenerates `RailsFast.xcodeproj` from that YAML. Do **not** edit Version or Build only in Xcode on the signing Mac: those edits live in the generated project and are easy to lose. Use the repo helper before archiving: ```bash # Normal new public release: 1.0.0 -> 1.0.1, build 1 -> 2 ruby scripts/ios_release_version # Another TestFlight build for the same public version: build only ruby scripts/ios_release_version --bump none # Exact sync after an emergency manual edit ruby scripts/ios_release_version --version 1.0.1 --build 2 ``` The helper updates `project.yml`, runs `xcodegen generate`, and leaves both `project.yml` and `RailsFast.xcodeproj/project.pbxproj` ready to commit and push. The signing Mac should then pull the committed version before archiving. Why not Apple's `agvtool` directly? `agvtool` is built for projects where `project.pbxproj` is the source of truth. It searches the Xcode project for `CURRENT_PROJECT_VERSION` and writes version values from there, while RailsFast iOS intentionally treats `project.yml` as the source of truth. If you later move signing and upload to CI, the network-aware versioning path is App Store Connect or fastlane. Apple exposes uploaded builds through the App Store Connect API, and fastlane's `latest_testflight_build_number` can derive the next build number from already-uploaded TestFlight builds. RailsFast does not add that dependency by default because deterministic source-controlled bumps are enough for local or remote-Mac signing. ## The Main Files ### App bootstrap - `RailsFast/App/AppDelegate.swift` - `RailsFast/App/SceneDelegate.swift` - `RailsFast/Core/AppConfiguration.swift` - `RailsFast/Resources/Info.plist` - `RailsFast/RailsFast.entitlements` - `project.yml` `AppDelegate.didFinishLaunching` does two things, in order: 1. `configureAppearance()` — nav bar and tab bar appearance 2. `configureHotwire()` — path config, user agent, custom web view, bridge components, route handlers Keep that order. Appearance is applied first so the chrome is correct before the first navigation. The window and root view controller are not created here — that happens in `SceneDelegate.scene(_:willConnectTo:)`, which is where the actual root shell gets set. `configureHotwire()` is the single registration point. It should stay boring and explicit. Important defaults: - `Hotwire.config.pathConfiguration.matchQueryStrings = false` - bundled path configuration first, then the remote Rails config - tests use only the bundled path configuration (deterministic) - `Hotwire.config.applicationUserAgentPrefix = "RailsFast iOS; RailsFast Native iOS;"` - `Hotwire.config.defaultViewController = { WebViewController(url: $0) }` - `Hotwire.config.makeCustomWebView = { RailsFastWebView(...) }` - `Hotwire.config.hidesTabBarWhenPushed = true` > Register bridge components and route handlers before any navigation starts. ## User Agent `AppConfiguration.userAgentPrefix` builds: ```text {CFBundleDisplayName} iOS; RailsFast Native iOS; ``` Hotwire Native appends the rest: ```text Hotwire Native iOS; Turbo Native iOS; bridge-components: [...] ``` That keeps the downstream app name, the RailsFast template identity, the Hotwire framework identity, and the registered bridge capabilities in one user agent without duplicating framework strings. This mirrors the [[native/3-android|Android]] layering exactly, so Rails' `hotwire_native_app?` and `railsfast_native_app?` detection behaves identically on both platforms. > [!IMPORTANT] > RailsFast Base gates bridge markup by the advertised `bridge-components` segment, not by platform. iOS only receives a toast/menu/overflow tag when it advertises that component in its UA. See [[native/2-railsfast-base|RailsFast Base for Native]] for the rule: gate bridge output by component support, not by platform. ## The iOS Architecture The iOS starter is a thin UIKit shell over Hotwire Native with **two roots** that swap on `window.rootViewController`: - a signed-out / onboarding `Navigator` (configuration name `auth`, starting at `/native/entry`) - a signed-in `RailsFastTabBarController` (a `HotwireTabBarController` subclass) `SceneDelegate` starts in the auth navigator. Rails then publishes shell state through the `native-shell` bridge component, using the same three-state vocabulary as Android and Rails: - `auth` — signed out, no tabs - `onboarding` — signed in but setup runs without tabs - `tabs` — signed in, native tab shell When Rails sends `tabs`, iOS swaps the window root to `RailsFastTabBarController` and loads the current tab catalog. The auth navigator is recreated fresh on each transition back to `.auth` / `.onboarding` for a clean slate — this mirrors Android's `finish()` + `startActivity(AuthActivity)` root swap. > [!NOTE] > Cookies survive the root swap because Hotwire's web views share the default `WKWebsiteDataStore` and process pool — not because the `Navigator` instance persists. The session is the same shared Devise cookie the web app uses. This is the same two-shell model as Android. Auth, welcome, and onboarding do not live inside the signed-in tab bar — that is deliberate, and it avoids the hybrid-app smell where sign-in screens look trapped under app tabs. > [!INFO] > The two-shell auth/tabs split is a RailsFast addition, not an official Hotwire Native pattern. The official iOS demo uses a single `HotwireTabBarController` root with no auth shell. Tab routing has one extra iOS-specific guard. The framework's `HotwireTabBarController.route(_:)` only ever routes through the currently selected navigator, so RailsFast iOS selects the owning tab first — and then must not race that tab's root load. The shell tracks, per tab root, whether the root's cold boot is still in flight, no matter who started it: the framework starting the selected tab at launch, a user tapping a cold tab, or shell routing itself. While a root is booting, any target other than the exact root URL already loading waits for `requestDidFinish(at:)` before routing — child routes and query-bearing roots such as `/settings?return_to=/dashboard` alike — and a newer route into the same booting tab replaces the waiting one rather than cancelling the root load. The finished signal fires for failed roots too, so a deep link is delivered (or shows the standard error screen) instead of being dropped. Tabs with a fully native root, like Account's `/native/me`, never issue a web request for their root, so nothing waits on them: routes go out immediately, which is equally race-free. Net effect: queries survive cold starts, and there are never two overlapping cold `WKWebView.load()` calls on one navigator. Key files: - `RailsFast/App/SceneDelegate.swift` - `RailsFast/Bridge/NativeShellComponent.swift` - `RailsFast/Core/ShellState.swift` - `RailsFast/Shell/RailsFastTabBarController.swift` ## Shell Handoff Rules The `native-shell` bridge is page-scoped, but shell changes are scene/root-controller state. So the bridge component posts a `NotificationCenter` event instead of reaching directly into `SceneDelegate`. That keeps the component decoupled from UIKit scene plumbing. Two details are load-bearing: 1. Shell transition notifications are dispatched on the **next** main-queue turn (`DispatchQueue.main.async`), not synchronously. 2. Modal bridge messages are ignored **unless** Rails marks the message as a handoff (`handoff == true || !isModalPresentation`). The async boundary gives sibling bridge components in the same HTML document a chance to fire before the root controller swaps. This matters for login success: the handoff document carries both a `toast` message and a `native-shell` message, and you want the verified-login toast to fire before the root swap deactivates the destination. This mirrors Android's `runOnUiThread` async boundary. The modal guard prevents a background modal page from reshaping the root shell just because its bridge connects while the presenting tab should stay in control. > [!CAUTION] > Removing the async boundary loses the auth-handoff toast. Do not make the notification synchronous. ## The Four Bridge Components iOS registers exactly four bridge components, in this order: `NativeShellComponent`, `ToastComponent`, `MenuComponent`, `OverflowMenuComponent`. Each one has a web `BridgeComponent` controller in RailsFast Base and an Android counterpart — the names, event names, and JSON payloads must match across all three. | Component | Web controller | Event in | Payload in | Reply | | --- | --- | --- | --- | --- | | `native-shell` | `railsfast--native--shell` | `connect` | `{state, handoff?, location?}` | — | | `toast` | `railsfast--native--toast` | `show` | `{message, severity}` | — | | `menu` | `railsfast--native--menu` | `display` | `{title, items: [{title, index}], source}` | `{selectedIndex}` | | `overflow-menu` | `railsfast--native--overflow-menu` | `connect` | `{label}` | `connect` (on tap) | - `severity` is one of `success | error | warning | info`. - `state` is one of `auth | onboarding | tabs`. > [!TIP] > Bridge components are for progressive native enhancement of a mostly-web screen — a native menu, a native toast, a native share affordance. They are **not** for root tabs. Tabs are shell infrastructure, not a bridge concern. ### Toasts RailsFast uses bridge-driven native toasts for nonblocking Rails flash feedback. The `toast` component receives `show` with a `{message, severity}` data object — `message` and `severity` are fields of the message data, not siblings of an event key. iOS has no first-party UIKit toast control, so RailsFast renders a custom UIKit material capsule with: - an SF Symbol severity icon - an adaptive blur material background - haptic feedback (success / warning / error notification haptics; a light impact for info) - a VoiceOver announcement - two-line support for validation messages - swipe-down dismissal, plus auto-dismiss Presentation rules: - normal visible pages render the toast in their source destination - `/native/handoff` login-success toasts render on the active foreground window after a short delay, so the verified-login toast survives the auth → tabs root swap - `/native/entry` logout transitions intentionally drop stale detached toasts > [!NOTE] > Why not a global SwiftUI toast root? The iOS shell is UIKit + Hotwire Native with an iOS 14 deployment target. A SwiftUI overlay root would only fully pay off on iOS 26+ and would wrap extra state around the navigator. Keeping toast ownership in the bridge component is simpler and aligned with the Hotwire page lifecycle. ### Menu and overflow menu (iOS 26 subtlety) These two components are small, but the iOS 26 details are where the work is. The **overflow menu** creates an **image-only** bar button (`UIImage(systemName: "ellipsis.circle")`, no title): ```swift // RailsFast/Bridge/OverflowMenuComponent.swift let button = UIBarButtonItem(image: UIImage(systemName: "ellipsis.circle"), primaryAction: action) button.accessibilityLabel = data.label if #available(iOS 16.0, *) { viewController.navigationItem.trailingItemGroups = [ UIBarButtonItemGroup(barButtonItems: [button], representativeItem: nil) ] } else { viewController.navigationItem.rightBarButtonItem = button } ``` > [!WARNING] > The overflow button must be image-only. On iOS 26 Liquid Glass, a bar button with a title is classified as a *text button*, gets its own glass background, and renders as a detached pill on the **leading** side. Image-only items group naturally on the trailing edge. It is also placed via `trailingItemGroups` on iOS 16+ rather than `rightBarButtonItem`, which is more reliable across iOS 26 glass layouts. The **menu** component builds a `UIAlertController(.actionSheet)` and anchors its popover to the **native bar button**, not the web source rect: > [!WARNING] > On iOS 26, action sheets present as popovers even on iPhone. The overflow menu's web source rect points at the hidden HTML trigger, whose coordinates do not match the native ellipsis button — so anchoring to it pins the popover to the wrong side of the screen. `MenuComponent` prefers `navigationItem.trailingItemGroups.first?.barButtonItems.first` (iOS 16+), falls back to `rightBarButtonItem`, then to the source rect, then to `view.bounds` with no arrow. The web stays the source of truth for what each menu item does — iOS replies with the server-provided `selectedIndex`. Key files: - `RailsFast/Bridge/NativeShellComponent.swift` - `RailsFast/Bridge/ToastComponent.swift` - `RailsFast/Bridge/MenuComponent.swift` - `RailsFast/Bridge/OverflowMenuComponent.swift` ## Route Decision Handlers Route handlers are registered in this order — first match wins: ```swift // RailsFast/App/AppDelegate.swift Hotwire.registerRouteDecisionHandlers([ TabRouteDecisionHandler(), // custom — cross-tab routing AppNavigationRouteDecisionHandler(), // stock — in-tab pushes SafariViewControllerRouteDecisionHandler(), // stock — external links in Safari SystemNavigationRouteDecisionHandler() // stock — mailto:, tel:, etc. ]) ``` > [!CAUTION] > `registerRouteDecisionHandlers([...])` **replaces** the entire default handler list — it does not append. You MUST re-list all three stock handlers (`AppNavigation`, `SafariViewController`, `SystemNavigation`) after your custom one, or Safari links, system navigation, and normal in-tab pushes silently break. The custom `TabRouteDecisionHandler` must come first so cross-tab routing happens before the generic same-host handler says "navigate in the current navigator." ## Native Tabs And Cross-Tab Routing RailsFast iOS uses a custom `RailsFastTabBarController` on top of Hotwire's `HotwireTabBarController`. Hotwire's stock tab controller routes through the currently selected tab's active navigator. RailsFast needs Android-equivalent ownership routing: before routing, the shell selects the tab that **owns** the URL. `TabCatalog` is the native model for the Rails `settings.shell.tabs` contract. The default RailsFast tabs are: - `home` starts at `/dashboard` - `account` starts at `/native/me` - `settings` starts at `/settings` The Settings tab owns a broader **route family**, not just its start URL: - `^/settings(?:/.*)?$` - `^/organizations(?:/.*)?$` - `^/memberships(?:/.*)?$` - `^/invitations(?:/.*)?$` - `^/billing(?:/.*)?$` > Tabs are not owned only by their start URL. They are owned by route families. `TabCatalog` accepts server-defined tabs only when they are safe for iOS: known RailsFast keys reuse native title/icon defaults, while unknown keys need an explicit title and a valid SF Symbol name (otherwise they are skipped with a warning). That keeps downstream apps flexible without letting arbitrary remote strings crash the native tab bar. Cross-tab routing lives in `TabRouteDecisionHandler` + `TabRoutingCoordinator`. It matches only when the source navigator's tab differs from the tab that owns the target URL. Unclaimed routes are **tab-agnostic**: `TabCatalog.tab(for:)` returns `nil`, the custom handler does not match, and Hotwire's stock handler pushes the URL onto the current tab. > [!NOTE] > `tab(for:)` returning `nil` means "stack on the current tab", not "use the default tab". Only `tabOrDefault(for:)` falls back to the default tab (used by cold launch). Mixing these up silently reassigns unclaimed URLs to Home. This mirrors Android's nullable `MainTabs.Catalog.tabFor` behavior exactly. Key files: - `RailsFast/Shell/Tabs.swift` - `RailsFast/Shell/RailsFastTabBarController.swift` - `RailsFast/Shell/TabRoutingCoordinator.swift` ## Path Configuration Rails is the source of truth for route policy. iOS loads path configuration from two sources, in order: 1. bundled fallback: `RailsFast/Resources/path-configuration.json` 2. Rails server: `/native/configurations/ios/v1.json` The bundled file is a safety net, not a second product contract. Keep it aligned with the Rails server config and with the [[native/3-android|Android]] catalog. > [!IMPORTANT] > `matchQueryStrings = false` is intentional. Path-config rules and tab ownership are path-family rules, so `/native/me?source=settings` resolves to the same rule and the same native screen as `/native/me`. Query strings are preserved on the URL but never change which rule, tab, or screen applies. The iOS rules use platform-idiomatic presentation properties (`view_controller`, `modal_style`, `presentation`) that Android does not — Android uses `uri` deeplinks instead. The shared part is `settings.shell` (the tab catalog); the `rules[]` arrays differ by platform. Do not copy iOS `modal_style` into Android. ### Auth sheet detents (iOS-only) - `/users/sign_up` opens as a `modal` with `modal_style: large` (full-height detent). The signup form is taller — email, password, password confirmation, terms — and a medium detent forced users to swipe up before they could see and accept the terms. - `/users/sign_in` opens as a `modal` with `modal_style: medium` (half-height). It is just email + password. > [!NOTE] > Sign-up is the tall one. Android ignores `modal_style` entirely and opens both as one combined expanded auth sheet, so this large-vs-medium distinction is iOS-specific. ## Native HTTP And The CSRF Posture Fully native screens (like the Account tab) reuse the shared Rails session instead of inventing a second auth system. Here is how that works on iOS: - Hotwire Native syncs `WKWebView` cookies into `HTTPCookieStorage.shared` after every page load (`Navigator.sessionDidFinishRequest`). - `NativeHttpClient` configures its `URLSession` with `httpCookieStorage = .shared`, so its requests automatically carry the Rails session cookie. - It sends `Accept: application/json` and decodes typed responses. > [!WARNING] > `NativeHttpClient` is **GET-only by design**. `URLSession` does not carry Rails' `X-CSRF-Token` from the WebView DOM, so native writes would fail CSRF checks. Cookie sync solves authentication continuity, not CSRF. Adding native write endpoints requires an explicit server contract — the blessed pattern is `skip_forgery_protection` scoped to JSON for authenticated native-only endpoints. See [[native/2-railsfast-base|RailsFast Base for Native]]. Key file: `RailsFast/Core/NativeHttpClient.swift` ## Session Recovery: 401 → Auth Shell When a Rails session expires, web and native should converge back on `/native/entry` rather than getting stuck. - `SceneDelegate.visitableDidFailRequest` treats `TurboError.http(401)` as "the session is gone" and calls `transitionToShell(.auth)` instead of recovering in-stack. - `NativeMeViewModel` separately maps a `401` JSON response to its `.unauthorized` state and invokes `onUnauthorized`, which `SceneDelegate` wires to `transitionToShell(.auth)`. Both paths rebuild the signed-out shell so the user lands cleanly on the welcome flow. Key file: `RailsFast/App/SceneDelegate.swift` ## Add A Native Screen The reference recipe is the Account tab (`Features/Me`), selected by the Rails path-config `view_controller: "me"` rule. Copy this four-part split instead of putting networking or route parsing inside SwiftUI views: - **`NativeMeViewController`** — a `UIViewController` conforming to `PathConfigurationIdentifiable`, hosting a `UIHostingController` pinned to all edges. This is the Hotwire destination. - **`NativeMeViewModel`** — a `@MainActor ObservableObject` owning the `loading / loaded / unauthorized / error` state machine, dedup, retry, and the fetch through `NativeHttpClient`. - **`NativeMeView`** — pure SwiftUI presentation, no networking. - **`NativeMePayload`** — the `Decodable` shape (with a flexible decoder for `credits`, since Rails may serialize counters as numbers or strings). > Keep the canonical Rails URL as the screen identity. To wire a new screen: add the view controller, give it a `pathConfigurationIdentifier`, map it in `SceneDelegate.handle(proposal:)`, and add a `view_controller` rule in the Rails path configuration. ## The Custom Web View RailsFast installs `RailsFastWebView` through `Hotwire.config.makeCustomWebView`. Use that hook instead of forking Hotwire Native. Its `configure()` currently owns: - `backgroundColor = .systemBackground` **and** `isOpaque = false` — together these let the system background show through, which is what keeps the chrome consistent during loads and transitions - disabling the horizontal scroll indicator and horizontal bounce - `isInspectable = true` under DEBUG (iOS 16.4+) to preserve Safari Web Inspector after replacing Hotwire's default factory - removing the iPhone HTML form accessory bar (the prev/next/done toolbar) > [!NOTE] > The form-accessory removal dynamically subclasses WebKit's private content view to override `inputAccessoryView`, because there is no public `WKWebView` switch for it. Keep that workaround centralized and easy to delete if WebKit ever exposes a public API. ### Outer-scroll lock for fullscreen surfaces `WebViewController.locksOuterScroll(for:)` disables the outer `WKScrollView` bounce for `/native/entry`, `/native/auth/welcome`, and any `/onboarding` prefix. These are fixed-chrome screens that own their layout in CSS, so the whole surface should not rubber-band away from the device edges — only the inner page body scrolls. This is part of the same "CSS owns the safe area" story as the Liquid Glass work below. ## iOS 26 Liquid Glass This is the standout work in the iOS shell, so it is worth understanding the problem before the fixes. iOS 26 changed navigation bars and tab bars to **Liquid Glass** — a translucent material that samples and blurs whatever is behind it. That is beautiful for native content, but a Hotwire app paints its content with a web view, and getting glass to look right over a web view took care. RailsFast iOS handles three page families differently, all driven by a small pure value type, `NativeChromePolicy`, which resolves chrome from two inputs: `hidesNavigationBar` and `supportsLiquidGlass`. ### Three page families **1. Fullscreen web chrome** (tab roots, onboarding, `/native/entry`, `/native/auth/welcome`): - hide the UIKit nav bar - extend the web view to all edges - `contentInsetAdjustmentBehavior = .never` - let web CSS own the safe area through `env(safe-area-inset-*)` **2. Visible native nav bar on iOS < 26:** - opaque native nav bar, web view does **not** extend under it - `contentInsetAdjustmentBehavior = .automatic` **3. Visible native nav bar on iOS 26+:** - let UIKit use Liquid Glass; extend the web view **under** the bar - `contentInsetAdjustmentBehavior = .automatic` - nudge the scroll offset to `-adjustedContentInset.top` so content starts below the bar Extending the web view under the glass gives it one continuous adaptive surface to sample, which avoids the two-tone artifact where the glass and the page resolve against different backing colors. > [!WARNING] > Do **not** force any of these on an iOS 26 navigation bar: `isTranslucent = false`, `backgroundColor = .systemBackground`, or `configureWithOpaqueBackground()`. All three were tried and reverted. `isTranslucent = false` makes bar buttons detach into floating glass pills; the other two double-layer the glass material into a gray tint. On iOS 26, let the default glass material own the bar surface. ### Why the shared WKWebView makes this hard Hotwire reuses **one** `WKWebView` per Session across visits. So scroll-view state — `contentInsetAdjustmentBehavior`, `contentOffset` — leaks between tab roots, pushed pages, and modal pages. > [!IMPORTANT] > `configureNativeWebViewChrome` runs on **every** `visitableDidActivateWebView` (push and pop), and must explicitly set both `.never` (fullscreen) and `.automatic` (visible-nav). Never assume the UIKit default still holds — a previous screen may have left the opposite value on the shared scroll view. ### Keep the WKWebView opaque RailsFast paints the shared WKWebView with `.systemBackground` so cold loads, overscroll, dark mode, and Liquid Glass all see the same adaptive surface. Keep the WKWebView itself **opaque** while doing so. This is a correctness contract, not just a drawing optimization. A standalone, dependency-free iOS 26.3.1 reduction naturally reproduced an intermittent native-content collapse only when a compact-sheet WKWebView combined these two public appearance settings: ```swift webView.isOpaque = false webView.backgroundColor = .systemBackground ``` The DOM, `WKWebView.bounds`, and every public zoom value remained at the correct device width / 1.0, while the visible document and `scrollView.contentSize.width` fell to approximately `deviceWidth / 980` (`402 -> 164.6667`, or `390 -> 155`). Either appearance property alone completed 100-cycle controls without a qualifying collapse. The reduced case needs no Hotwire, Rails, network request, navigation controller, detent change, form, or representative app CSS. RailsFast therefore keeps `.systemBackground` and explicitly sets `isOpaque = true`. Removing only the initial background assignment is not a valid fix: `configureNativeWebViewChrome` reapplies the route's adaptive backing on every activation. Keeping the view opaque preserves all intended visuals and removes the known trigger. The shell still retains its public-API recovery guard. Prevention cannot update already-installed binaries, and a future WebKit version may reach the same inconsistent postcondition through another path. For an attached, loaded, non-zoomable document whose native content width is below 80% of its viewport, `RailsFastWebView` uses a visually indistinguishable `setZoomScale(1.000001)` nudge under a temporary 1.01 maximum, restores the original zoom contract and logical scroll position, and never reloads or mutates the DOM. Checks cover render, bounds settling, window attachment, appearance, and app resume. The natural stress loop intentionally lives outside product CI; RailsFast's unit suite injects the captured public/native postcondition deterministically to test recovery without making ordinary builds flaky. Exact sample, screenshots, property controls, and raw public-API JSONL evidence: - https://github.com/rameerez/WKWebViewSheetViewportRepro/tree/dc68650bacffc75c9d07f4ef3c18cc30c0333333 - https://github.com/rameerez/WKWebViewSheetViewportRepro/blob/dc68650bacffc75c9d07f4ef3c18cc30c0333333/evidence/README.md#property-level-controls The exact upstream report is listed first, followed by neighboring legacy-viewport and resize states that do not describe this precise healthy-DOM/native-only divergence: - Exact reduced report: https://bugs.webkit.org/show_bug.cgi?id=319180 - https://bugs.webkit.org/show_bug.cgi?id=262207 - https://bugs.webkit.org/show_bug.cgi?id=170595 - https://bugs.webkit.org/show_bug.cgi?id=267545 - https://bugs.webkit.org/show_bug.cgi?id=191872 WebKit's animated-resize implementation is a plausible source-level mechanism, not a proven root cause—the reduced case needs no detent transition after presentation: https://github.com/WebKit/WebKit/blob/3489b26dd844b8115f10e23f7d194fcd9055d6e2/Source/WebKit/UIProcess/API/ios/WKWebViewIOS.mm#L3417-L3435 ### The cold-modal first-paint bug This one was subtle enough to keep documented. - **Symptom:** the **first** open of `/users/sign_up` or `/users/sign_in` painted web content behind the native nav bar. Second and later opens were fine. Pushed screens like `/settings` were always fine. - **Root cause:** a modal `Navigator` has its own modal Session. On first open that session cold-boots a new `WKWebView` (a `ColdBootVisit`). The pre-render nudge correctly set the offset, then the cold-page render reset `contentOffset.y` back to `0`. On second open the modal web view already holds the page, so activation has real size and the early nudge sticks — which is why the bug was first-open-only. - **The fix that worked:** keep the normal lifecycle nudge for pushed/cached screens, then add a **second** render-phase nudge in `visitableDidRender()`. That is the first native hook after the cold-modal render reset, so it repairs without observers, timers, forced layout, or page JS. It only moves the scroll view when it is still effectively at the native top (`contentOffset.y <= 1` — an inclusive 1pt tolerance for fractional offsets during the sheet animation); real Turbo scroll restoration (offset > 1) is left alone. > [!NOTE] > The nudge cascade runs across `visitableDidActivateWebView → viewDidLayoutSubviews → viewDidAppear`, plus the `visitableDidRender` render-phase correction. `adjustedContentInset.top` is still 0 during `visitableDidActivateWebView` (view not laid out yet), so the nudge cannot happen there alone. A forced-layout / KVO experiment regressed modal geometry — do not force layout during the sheet animation. ### The iOS < 26 dock gap Fullscreen surfaces own their bottom safe area in CSS (`env(safe-area-inset-bottom)`, `position: fixed; bottom: 0`). On iOS < 26 the default `.automatic` adds a bottom safe-area inset, so the CSS viewport ends up inset and `bottom: 0` lands at the safe-area edge, not the device edge — and `env()` returns 0 on the very first paint before `viewport-fit=cover` settles, so the page's compensation produces a visible gap above the home indicator. > [!CAUTION] > The fix is order-dependent: fullscreen surfaces are checked **first, regardless of iOS version**, and forced to `.never`. This was previously gated inside `#available(iOS 26.0, *)` and never ran on iOS 18, which caused the onboarding dock gap. Because the WebView is shared, the visible-nav iOS < 26 branch must also **restore** `.automatic` so `.never` does not leak into the next page. Key file: `RailsFast/Shell/WebViewController.swift` ## Deep Links RailsFast iOS supports two deep-link classes: - **Custom scheme** (`railsfast://`) for development and automation — no AASA needed, registered in `Info.plist`. - **Universal Links** (production HTTPS) — declared via the Associated Domains entitlement. `SceneDelegate` canonicalizes both forms into internal app URLs (relative paths become absolute, localhost aliases are normalized in dev, external hosts are rejected, query strings are preserved) and then routes through `RailsFastTabBarController.routeDeepLink` — or stashes the link as a pending deep link and replays it once the shell reaches `.tabs`. Test the custom scheme on a booted simulator: ```bash xcrun simctl openurl booted "railsfast:///dashboard?deeplink_smoke=1" xcrun simctl openurl booted "railsfast:///settings?return_to=/dashboard" xcrun simctl openurl booted "railsfast:///native/me?source=settings" ``` For tab-root links, expect: the correct tab selected, the tab bar visible, the query string preserved, and no duplicate pushed root page. > [!NOTE] > A tab-root deep link with a query string must not be treated like a normal push. A normal `.advance` visit to `/dashboard?x` pushes a duplicate `/dashboard` above the real tab root, and because `hidesTabBarWhenPushed` is on, that hides the native tab bar. The fix: detect tab-start paths (ignoring query string), dismiss any modal, pop to root, and route with `VisitOptions(action: .replace)` + `resetToRoot` — preserving the query without a duplicate root. Universal Links require a real HTTPS domain and a valid AASA file. Localhost can validate the custom scheme and the server payload, but not Apple's full domain trust flow. ## Universal Links And AASA Universal Links are a two-sided trust relationship: 1. The iOS app declares an Associated Domains entitlement, e.g. `applinks:yourdomain.com`. 2. Rails serves an Apple App Site Association file listing the app ID and the claimed paths. On the iOS side, the entitlement uses the `$(RAILSFAST_ASSOCIATED_DOMAIN)` build variable, which defaults to the placeholder `example.com`. Replace it with your real production host before shipping. > [!IMPORTANT] > `RAILSFAST_ASSOCIATED_DOMAIN` ships as the placeholder `example.com`. It is a value you must replace, not a host that does anything. Set it to your real domain in `project.yml` and run `xcodegen generate`. The Rails side serves `/.well-known/apple-app-site-association` (and a byte-identical alias at `/apple-app-site-association`). The path list is server-owned — it lives in `config/railsfast/railsfast.yml` under `native.ios.app_link_paths`. With no Team ID / bundle / paths configured, the endpoint fails closed and returns a valid non-claiming payload, so the template never publishes a fake Apple app claim. > [!CAUTION] > Keep path lists explicit. Do not claim `/*` unless the native app should open every product, marketing, legal, support, and auth URL on the domain. The endpoint must stay public, cacheable JSON with a `no-transform` cache directive — a CDN that rewrites the body breaks verification. See [[native/2-railsfast-base|RailsFast Base for Native]] for the full app-link contract. ## Testing The iOS Template `RailsFastTests/AppConfigurationTests.swift` is the **single test file**, and it encodes the whole contract. If you add a behavior worth protecting, add an assertion here. It covers: - base URL stays template-generic; user-agent identity; custom scheme registered - entitlement keeps `applinks:$(RAILSFAST_ASSOCIATED_DOMAIN)` and the `example.com` default - URL canonicalization (localhost aliases, IPv6 `::1`, rejecting wrong port / scheme / external / protocol-relative) - `jsonURL` behavior; default + remote tab catalog parsing; `matchQueryStrings = false` - the bundled path-config contract (account tab, `me` view controller, onboarding replace + hide nav bar) - `NativeMePayload` flexible credits decoder - every toast `PresentationStrategy` branch and its delays - the full `NativeChromePolicy` matrix and the glass nudge decision tables (including the inclusive 1pt top tolerance) - the opaque adaptive WKWebView prevention contract plus deterministic collapsed-content detection/recovery, including detached-session rejection Run it: ```bash cd ~/GitHub/railsfast-ios xcodebuild test -project RailsFast.xcodeproj -scheme RailsFast -destination 'platform=iOS Simulator,name=iPhone 16 Pro' ``` > [!TIP] > When investigating layout bugs, run on both an **iOS 18** and an **iOS 26** simulator. They exercise different nav bar and safe-area paths, and the Liquid Glass code is injected with a `supportsLiquidGlass` flag so the policy can be tested both ways on one simulator. ## What To Customize In iOS ### Change app identity Edit `project.yml`, then `xcodegen generate`. Use it for: app display name, bundle ID, base URL, custom URL scheme, associated domain, navigation title font hook. ### Change the app icon and launch screen Use [[native/6-icons-and-launch-screens|App Icons and Launch Screens]] for the complete asset-generation and cold-launch verification workflow. iOS derives installed icon sizes from one opaque 1024x1024 source, while the static `UILaunchScreen` and runtime `StartupLoadingView` must share the same `LaunchBackground` and optional `LaunchLogo` assets to avoid a white WebView interval. ### Change native navigation title fonts Native nav titles are UIKit text, not Rails HTML — CSS or Google Fonts loaded by the Rails app do not touch `UINavigationBar`, native tab labels, or SwiftUI-native screens. RailsFast iOS has one narrow hook in `AppDelegate` (`BrandAppearance.navigationTitleFontName`) that reads `NAVIGATION_TITLE_FONT_NAME` from `Info.plist` (a PostScript name), falling back to the system font if blank or unresolved. To add a title font: add the `.ttf`/`.otf` files to a resource folder, register them under `UIAppFonts` in `Info.plist`, then set `NAVIGATION_TITLE_FONT_NAME` to the font's PostScript name. ```yaml # project.yml settings: base: RAILSFAST_NAVIGATION_TITLE_FONT_NAME: Inter-SemiBold info: properties: NAVIGATION_TITLE_FONT_NAME: "$(RAILSFAST_NAVIGATION_TITLE_FONT_NAME)" UIAppFonts: - Fonts/Inter-SemiBold.ttf ``` > [!TIP] > Use `UIFont.familyNames` / `UIFont.fontNames(forFamilyName:)` to confirm the real PostScript name — it is not the file name, and `UIFont(name:size:)` needs the internal name. For SwiftUI-native screens, add a shared SwiftUI font/theme layer rather than styling each screen ad hoc. ### Change supported native tabs Edit `RailsFast/Shell/Tabs.swift`, the bundled `RailsFast/Resources/path-configuration.json`, and the Rails path configuration in RailsFast Base. The server can hide, show, and route supported tabs, but the iOS binary still needs a safe native representation (title + SF Symbol) for every supported tab. ### Change shell behavior Edit `SceneDelegate.swift` / `NativeShellComponent.swift` — only when the actual shell architecture changes. ### Change page chrome or visual polish Edit `WebViewController.swift` / `AppDelegate.swift` for nav bar behavior, Liquid Glass handling, safe-area behavior, and modal/pushed page chrome. ## When To Put Something In iOS Instead Of Rails Ask the same question as the rest of the native docs: > Is this a product rule or a native container detail? Put it in iOS when it is a true container concern: scene/window setup, view controllers, the tab bar, iOS-only transitions, glass/safe-area chrome, native UI affordances. Keep it in RailsFast Base when it is really a route-ownership rule, a tab-policy rule, an onboarding rule, or a billing/pricing policy. Those belong on the server so they can change without an App Store review. ## Production Readiness Checklist Before shipping a downstream iOS app: - Set a real bundle ID, Apple Team ID, and associated domain. - Bump version/build with `ruby scripts/ios_release_version`, commit the regenerated Xcode project, and pull that commit on the signing Mac before archiving. - Keep `RAILSFAST_ASSOCIATED_DOMAIN` aligned with the production host. - Configure Rails AASA app IDs and paths in `config/railsfast/railsfast.yml`. - Verify `/.well-known/apple-app-site-association` over HTTPS with no redirects or HTML. - Verify the custom scheme still works in development. - Verify Universal Links on a simulator or real device. - Test login, logout, onboarding, tabs, pushed pages, modal sheets, and app relaunch. - Test iOS 18 and iOS 26 if the deployment target spans both visual systems. ## Related Docs - [[native/1-overview|Native Overview]] - [[native/2-railsfast-base|RailsFast Base for Native]] - [[native/3-android|RailsFast Android]] - [[native/5-customization|Customization Workflow]] - [[native/6-icons-and-launch-screens|App Icons and Launch Screens]] - [[native/7-notifications|Notifications]] ## References - Apple Info.plist version keys: https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html - Apple `agvtool` versioning reference: https://developer.apple.com/library/archive/qa/qa1827/_index.html - App Store Connect API builds: https://developer.apple.com/documentation/appstoreconnectapi/builds - fastlane latest TestFlight build number: https://docs.fastlane.tools/actions/latest_testflight_build_number/ - XcodeGen project spec: https://yonaskolb.github.io/XcodeGen/Docs/ProjectSpec.html --- ## 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/1-overview|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: ```bash # 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\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: ```bash ./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: ```bash # In railsfast-ios xcodegen generate ``` App identity is not the same as visual branding. After the rename, follow [[native/6-icons-and-launch-screens|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: - [[native/4-ios#change-native-navigation-title-fonts|RailsFast iOS]] always shows a native UIKit navigation bar, so the starter ships a real font hook (`NAVIGATION_TITLE_FONT_NAME` + `UIAppFonts`). - [[native/3-android#native-title-fonts|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: ```json // 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: ```json // 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: ```ruby # 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 [[native/2-railsfast-base#6-bridge-contracts|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 [[guides/updating|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. ## Related Docs - [[native/1-overview|Native Overview]] - [[native/2-railsfast-base|RailsFast Base for Native]] - [[native/3-android|RailsFast Android]] - [[native/4-ios|RailsFast iOS]] - [[native/6-icons-and-launch-screens|App Icons and Launch Screens]] --- ## App Icons and Launch Screens App icons and launch screens are native binary assets. They belong in `railsfast-ios` and `railsfast-android`, not in RailsFast Base. The web welcome screen is different: Rails owns that screen at `app/views/native/auth/show.html.erb`. You should make the three surfaces feel continuous, but do not confuse their ownership: - the app icon is installed with the native binary - the system launch screen is installed with the native binary - the first interactive welcome/auth screen is Rails-rendered HTML > A polished launch feels like one continuous surface even though the operating > system, native shell, and Rails page render it in three separate phases. This guide gives you a reproducible workflow for both native repos, including the platform differences that are easy to miss. ## Prepare The Canonical Artwork Start from the same brand source files for both platforms: 1. **App mark** -- the compact symbol that still reads at small sizes. Prefer SVG as the canonical source. A large transparent PNG also works. 2. **Wordmark** -- the horizontal product logo, exported with transparency. This is useful for the iOS launch surface and the Rails welcome screen. 3. **Brand background color** -- one exact sRGB hex value, such as `#F3FF6B`. 4. **Optional monochrome mark** -- a single-color version for Android themed icons and, if you choose, custom iOS tinted icon artwork. Keep the mark and wordmark separate. A horizontal wordmark usually becomes illegible inside a small launcher icon, while a compact mark often looks too weak on a full-screen launch surface. > [!IMPORTANT] > Do not bake rounded corners, circles, squircle masks, drop shadows, or device > chrome into the source artwork. iOS and Android apply their own masks and > presentation effects. A pre-masked source produces double corners, excess > padding, or clipping on some launchers. Before exporting: - convert text to outlines so missing fonts cannot change the logo - trim accidental transparent padding around the source art - use sRGB - keep edges crisp and high contrast - inspect the mark at actual Home Screen size, not only at 1024px - commit the generated binary assets so CI and other developers build the same application The commands below use [ImageMagick](https://imagemagick.org/) for deterministic PNG generation: ```bash brew install imagemagick ``` ImageMagick is optional. Xcode and Android Studio can generate the same outputs through their asset editors. The important part is the output contract, not the specific graphics tool. ## iOS App Icon RailsFast iOS uses a single-size app-icon asset catalog: ```text RailsFast/Resources/Assets.xcassets/ AppIcon.appiconset/ Contents.json AppIcon-1024.png ``` Apple lets iOS and iPadOS generate the installed icon sizes from one 1024x1024 source image. The source must be a square, fully opaque image. Do not add rounded corners; iOS applies the final mask. ### Generate The 1024px Source This example places a transparent mark on an opaque brand-color canvas. The `650x650` mark box is a visual starting point, not an Apple requirement. Adjust it until the symbol has the right optical size while retaining enough margin for the system mask. ```bash # Run from the railsfast-ios repo root. BRAND_COLOR="#F3FF6B" MARK_SOURCE="/absolute/path/to/app-mark.png" ICON_OUTPUT="RailsFast/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png" mkdir -p /tmp/railsfast-brand magick "$MARK_SOURCE" \ -trim +repage \ -resize "650x650>" \ -gravity center \ -background none \ -extent 1024x1024 \ /tmp/railsfast-brand/icon-mark.png magick \ -size 1024x1024 "xc:${BRAND_COLOR}" \ /tmp/railsfast-brand/icon-mark.png \ -gravity center \ -compose over \ -composite \ -alpha off \ -colorspace sRGB \ "PNG24:${ICON_OUTPUT}" ``` If the mark starts as SVG, ImageMagick rasterizes it at the requested output size. Inspect the result carefully; SVG files with unsupported filters or external fonts may render differently from the design tool. Exporting a large transparent PNG from the design source first is the conservative fallback. Verify the basic file contract: ```bash sips \ -g pixelWidth \ -g pixelHeight \ -g hasAlpha \ RailsFast/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png ``` Expected: ```text pixelWidth: 1024 pixelHeight: 1024 hasAlpha: no ``` Then reference the file from `RailsFast/Resources/Assets.xcassets/AppIcon.appiconset/Contents.json`: ```json { "images" : [ { "filename" : "AppIcon-1024.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" } ], "info" : { "author" : "xcode", "version" : 1 } } ``` The target already selects this set through `ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon` in `project.yml`. ### Dark And Tinted iOS Icons iOS can apply automatic dark and tinted treatments to the primary icon. That is the simplest starting point and requires only the `Any` 1024px asset above. If the automatic treatment damages contrast or brand recognition, add explicit Dark and Tinted appearances in Xcode's asset inspector. Apple requires custom tinted artwork to be grayscale. Test all appearances on a real Home Screen; the source PNG can look correct while the system-generated dark treatment looks materially different. Apple's current asset-catalog documentation: - https://developer.apple.com/documentation/xcode/configuring-your-app-icon - https://developer.apple.com/design/human-interface-guidelines/app-icons ## iOS Launch Screen RailsFast iOS uses two visually matching phases: 1. `UILaunchScreen` -- the static, system-owned launch screen shown before app code runs. 2. `StartupLoadingView` -- the app-owned continuation shown while Hotwire loads the first real auth, onboarding, or tab destination. The two phases deliberately use the same named assets: ```text RailsFast/Resources/Assets.xcassets/ LaunchBackground.colorset/ LaunchLogo.imageset/ # optional but recommended for branded apps ``` The static launch screen cannot execute code, animate a spinner, fetch data, or contain buttons. Keep it to a background color and logo. `StartupLoadingView` adds the activity indicator after UIKit takes control. > [!WARNING] > Do not put sign-in buttons, localized marketing copy, a progress percentage, > or fake interactive controls into launch artwork. The launch screen is a > static placeholder, not the first page of the app. ### Set The Background Color Open `Assets.xcassets` in Xcode, select `LaunchBackground`, and set its universal sRGB color to the exact brand value. Using the asset editor avoids manually converting hex channels into the decimal values stored by `Contents.json`. If your brand has distinct light and dark launch colors, add an appearance variant intentionally. Otherwise keep one universal color so the launch surface does not change unexpectedly with system appearance. ### Generate The Horizontal Launch Logo RailsFast's runtime loading view caps the logo at 288 points wide. These raster sizes therefore form a practical 1x/2x/3x set: | Scale | Canvas | | --- | --- | | 1x | 288x64 px | | 2x | 576x128 px | | 3x | 864x192 px | The 64-point height is a RailsFast convention, not an Apple platform requirement. It gives a horizontal wordmark a stable transparent canvas while preserving its aspect ratio. ```bash # Run from the railsfast-ios repo root. WORDMARK_SOURCE="/absolute/path/to/wordmark.png" LAUNCH_SET="RailsFast/Resources/Assets.xcassets/LaunchLogo.imageset" mkdir -p "$LAUNCH_SET" magick "$WORDMARK_SOURCE" \ -trim +repage \ -resize 288x64 \ -gravity center \ -background none \ -extent 288x64 \ "$LAUNCH_SET/LaunchLogo.png" magick "$WORDMARK_SOURCE" \ -trim +repage \ -resize 576x128 \ -gravity center \ -background none \ -extent 576x128 \ "$LAUNCH_SET/LaunchLogo@2x.png" magick "$WORDMARK_SOURCE" \ -trim +repage \ -resize 864x192 \ -gravity center \ -background none \ -extent 864x192 \ "$LAUNCH_SET/LaunchLogo@3x.png" ``` ImageMagick preserves the wordmark's aspect ratio by default. `-extent` adds transparent padding to reach the exact canvas; it does not stretch the logo. Create `LaunchLogo.imageset/Contents.json`: ```json { "images" : [ { "filename" : "LaunchLogo.png", "idiom" : "universal", "scale" : "1x" }, { "filename" : "LaunchLogo@2x.png", "idiom" : "universal", "scale" : "2x" }, { "filename" : "LaunchLogo@3x.png", "idiom" : "universal", "scale" : "3x" } ], "info" : { "author" : "xcode", "version" : 1 } } ``` ### Connect The Launch Assets Configure the static launch screen in `project.yml`: ```yaml # project.yml targets: RailsFast: info: properties: UILaunchScreen: UIColorName: LaunchBackground UIImageName: LaunchLogo UIImageRespectsSafeAreaInsets: true ``` Then regenerate the Xcode project: ```bash xcodegen generate ``` Keep the asset names `LaunchBackground` and `LaunchLogo` unless you also update `StartupLoadingView.backgroundAssetName` and `StartupLoadingView.logoAssetName`. A name mismatch can make the static launch screen or runtime continuation silently lose its artwork. `StartupLoadingView` is window-level on purpose. It stays visible across the intentionally blank signed-in `/native/entry` handoff and disappears only when the final auth, onboarding, or tab destination renders. Request failures remove it immediately so error and retry UI can never remain hidden behind a splash. Apple's launch-screen references: - https://developer.apple.com/documentation/xcode/specifying-your-apps-launch-screen - https://developer.apple.com/documentation/bundleresources/information-property-list/uilaunchscreen - https://developer.apple.com/design/human-interface-guidelines/launching - https://developer.apple.com/documentation/technotes/tn3118-debugging-your-apps-launch-screen ### Clear The iOS Launch Cache iOS caches launch-screen snapshots. An ordinary rebuild can continue showing old artwork even when the asset catalog is correct. For a reliable verification: 1. Stop the app. 2. Delete it from the Simulator or device. 3. Clean/build again. 4. Reinstall and cold-launch. From the command line: ```bash BUNDLE_ID="com.example.myapp" xcrun simctl terminate booted "$BUNDLE_ID" 2>/dev/null || true xcrun simctl uninstall booted "$BUNDLE_ID" 2>/dev/null || true ``` Then install/run from Xcode. Apple specifically recommends removing and reinstalling the app when debugging stale launch artwork. ## Android Launcher Icon Android launcher icons are adaptive assets, not one pre-masked square image. The color icon has two independent layers: - foreground -- the transparent app mark - background -- a full-bleed color or image Add a monochrome foreground layer so Android can render themed icons. Android launchers apply their own circle, squircle, rounded-square, or OEM-specific mask, so the foreground must survive all of them. Android's adaptive-icon design contract is: - every layer represents a 108x108dp canvas - the center 66x66dp is the guaranteed safe zone - the outer 18dp on each edge is reserved for masking and motion - the visible logo should be at least 48x48dp and no larger than 66x66dp - foreground and background layers must not contain their own outer mask or shadow Source: https://developer.android.com/develop/ui/compose/system/icon_design_adaptive ### Generate With Android Studio Android Studio's Image Asset Studio is the recommended generator because it previews the adaptive masks and creates both modern and legacy resources. 1. Open the Android repo in Android Studio. 2. In the Project window, right-click `app`. 3. Choose **New > Image Asset**. 4. Choose **Launcher Icons (Adaptive and Legacy)**. 5. Set the asset name to `ic_launcher`. 6. On **Foreground Layer**, select the SVG or large transparent PNG app mark. 7. Adjust scaling until the meaningful mark stays inside the safe-zone preview. 8. On **Background Layer**, choose the exact brand color or a full-bleed image. 9. On **Monochrome Layer**, provide the single-color mark when the tool exposes that option. 10. Inspect circle, squircle, rounded-square, and legacy previews. 11. Finish and review the generated Git diff before committing it. Image Asset Studio documentation: https://developer.android.com/studio/write/image-asset-studio Expected generated resources include: ```text app/src/main/res/ mipmap-anydpi-v26/ ic_launcher.xml ic_launcher_round.xml mipmap-mdpi/ ic_launcher.png ic_launcher_round.png mipmap-hdpi/ ic_launcher.png ic_launcher_round.png mipmap-xhdpi/ ic_launcher.png ic_launcher_round.png mipmap-xxhdpi/ ic_launcher.png ic_launcher_round.png mipmap-xxxhdpi/ ic_launcher.png ic_launcher_round.png ``` The legacy launcher sizes are 48, 72, 96, 144, and 192 pixels respectively. Let Image Asset Studio generate them; do not hand-resize the already-masked iOS icon into each directory. RailsFast Android already points the manifest at these names: ```xml ``` If you keep the `ic_launcher` names, no manifest change is required. ### Understand The Template's Layers The stock template implements the same contract with: ```text app/src/main/res/ drawable-nodpi/railsfast_icon.png drawable/ic_launcher_background.xml drawable/ic_launcher_foreground.xml mipmap-anydpi/ic_launcher.xml mipmap-anydpi/ic_launcher_round.xml mipmap-*/ic_launcher.png mipmap-*/ic_launcher_round.png ``` `ic_launcher_foreground.xml` applies an 18dp inset before centering the bitmap, which matches Android's reserved outer adaptive-icon region. A downstream app can replace these template resources directly, or let Image Asset Studio generate a conventional `mipmap-anydpi-v26` set. Do not leave stale adaptive XML pointing at the old RailsFast layers. After generation, inspect both `mipmap-anydpi` and `mipmap-anydpi-v26`; make sure the resource Android selects references your new foreground, background, and monochrome assets. An adaptive icon definition should have all three layers: ```xml ``` Use a dedicated monochrome drawable when the normal foreground contains multiple colors or effects. ## Android Splash Screen RailsFast Android uses the AndroidX SplashScreen API. Do not add a dedicated `SplashActivity`; Android 12 and newer always create a system starting window, and a second custom splash activity causes duplicated launch screens. The load-bearing pieces already exist: - `AuthActivity` is the launcher activity. - Its manifest theme is `Theme.RailsFastAndroid.Splash`. - `AuthActivity.onCreate()` calls `installSplashScreen()` before `super.onCreate()`. - The splash theme switches to `Theme.RailsFastAndroid` through `postSplashScreenTheme`. Customize both `app/src/main/res/values/themes.xml` and `app/src/main/res/values-night/themes.xml`: ```xml ``` Add the exact color to `app/src/main/res/values/colors.xml`: ```xml #FFF3FF6B ``` The first two hex digits are the alpha channel (`FF` means fully opaque), followed by the six-digit RGB color. > [!IMPORTANT] > Update the night theme deliberately. If the launch surface should always use > the brand color, reference the same background in both files. If dark mode > intentionally uses a different background, verify the splash icon has enough > contrast in both modes. Leaving the template's old night color in place > produces a launch screen that changes brand unexpectedly after sunset. ### Use The Compact Mark On Android The Android system splash is not a free-layout screen. Its centered icon is masked using adaptive-icon geometry, and one-third of the foreground can be clipped. The default RailsFast setup therefore uses `@mipmap/ic_launcher`. In most products: - use the compact app mark on Android's system splash - use the horizontal wordmark on iOS when it fits the brand - share the exact background color and overall visual language This intentional asymmetry is more native than forcing the same horizontal asset into both systems. Android supports `windowSplashScreenBrandingImage`, but the official design guidance recommends against using it. If you nevertheless have a product requirement for that bottom branding slot, the image must be 200x80dp and must be tested across screen sizes. Do not use it merely to make Android imitate iOS. The centered splash-icon dimensions are also constrained: - with an icon background: 240x240dp canvas, artwork inside a 160dp circle - without an icon background: 288x288dp canvas, artwork inside a 192dp circle Source: https://developer.android.com/develop/ui/views/launch/splash-screen > [!CAUTION] > AndroidX documents a compatibility difference below API 31: an adaptive icon > supplied directly as `windowSplashScreenAnimatedIcon` can be cropped and > scaled differently. RailsFast's minimum is API 28, so test at least one API > 28-30 emulator. If the result is wrong, use the adaptive foreground drawable > as `windowSplashScreenAnimatedIcon` and set > `windowSplashScreenIconBackgroundColor` to the adaptive background color, as > described in the AndroidX SplashScreen API reference: > https://developer.android.com/reference/androidx/core/splashscreen/SplashScreen ### Do Not Hold The System Splash On Network Work The system splash disappears when the app draws its first frame. Do not keep it on-screen while waiting for Rails, authentication, or arbitrary network I/O. That turns a system launch affordance into an indefinite blocking screen. If a visible WebView-loading gap needs product polish, implement a normal, accessible in-app loading surface and remove it on success or failure. Keep the system SplashScreen API responsible only for process/activity startup. ## Clear Android Launcher And Splash Caches Launchers can cache installed icons. Rebuilding the APK is not always enough to prove that a new adaptive icon or splash resource is installed. Use a clean reinstall: ```bash PACKAGE_ID="com.example.myapp" adb uninstall "$PACKAGE_ID" 2>/dev/null || true ./gradlew installDebug adb shell am force-stop "$PACKAGE_ID" adb shell monkey \ -p "$PACKAGE_ID" \ -c android.intent.category.LAUNCHER \ 1 ``` Capture the current emulator screen when reviewing masks or launch behavior: ```bash adb exec-out screencap -p > /tmp/android-launch.png ``` If the launcher still displays stale art after uninstall/reinstall, restart the emulator or clear the launcher app's cache. Clearing launcher data also resets the Home Screen layout, so use it only on a disposable emulator. ## Cross-Platform Verification Checklist Do not approve icons from source files alone. Verify the installed result. ### iOS - 1024x1024 source is fully opaque and sRGB. - Mark remains legible after the Home Screen mask. - Light, Dark, and Tinted Home Screen appearances are acceptable. - iPhone and iPad render the expected icon. - Cold launch shows the new static background and logo. - Static launch screen and `StartupLoadingView` do not jump in position or size. - No white frame appears between native launch and the first Hotwire page. - A failed initial request reveals retry/error UI instead of leaving the launch cover forever. - Verify at least iOS 18 and iOS 26 when supporting both visual systems. ### Android - Circle, squircle, rounded-square, and legacy icon previews are acceptable. - Foreground mark stays in the adaptive safe zone. - Themed icon uses a valid monochrome layer. - Light and dark launch themes use intentional colors. - Cold start shows one system splash, not a custom-splash duplicate. - Android 12+ masking does not clip the splash mark. - Test at least one current API emulator and one older supported API level. - Verify the installed release build too; resource shrinking and release signing should not change the selected launcher resources. ### Both - Icon and splash use the same canonical mark/color sources. - Native launch styling flows naturally into the Rails welcome/auth page. - No buttons or interactive-looking controls appear in static artwork. - Brand-specific assets remain downstream app code; only the reusable loading and generation machinery belongs upstream in RailsFast templates. ## Related Docs - [[native/0-quickstart|RailsFast Native Quickstart]] - [[native/3-android|RailsFast Android]] - [[native/4-ios|RailsFast iOS]] - [[native/5-customization|Customization Workflow]] --- ## RailsFast Native - Notifications RailsFast Native push is a three-part contract: - Rails owns notification events, device storage, and the authenticated token endpoint. - iOS owns APNs permission, token registration, and notification-tap routing. - Android owns FCM setup, token registration, runtime notification permission, and notification-tap routing. > [!IMPORTANT] > Native push is opt-in. The native shells can advertise and implement the `native-push` bridge component safely, but a downstream app should only render the Rails bridge tag once the matching server delivery credentials are configured for that platform. In particular, do not let Android register FCM tokens while the Rails server still has placeholder Google/FCM credentials. ## The Contract The web bridge component name is: ```text native-push ``` Rails renders a hidden bridge element on authenticated native pages. The web side sends: ```json { "event": "register" } ``` Native replies: ```json { "token": "device-token-from-apns-or-fcm", "platform": "ios" } ``` Android replies with `"platform": "android"`. Rails then POSTs that token from inside the WebView to the app's authenticated token endpoint, usually: ```text POST /notification_tokens ``` The POST should ride the existing Rails session cookie and CSRF token. Do not add a parallel mobile auth token just for push registration. The server stores tokens against the current user. If you use `action_push_native`, translate the OS names that native sends into the transport names the gem stores: - `ios` -> `apple` - `android` -> `google` Notification payload routing uses two app-defined data keys: - `path` for app-owned Rails paths, such as `/notifications` or `/rides/123` - `url` for absolute URLs when a downstream app needs them `url` wins over `path`. Native should route internal URLs in-app and open external URLs through the system browser. ## Rails Server The recommended Rails stack is: - `noticed` for event orchestration and in-app feed rows - `action_push_native` for APNs/FCM delivery and push-device records - a small app-owned `NotificationTokensController` - a RailsFast-owned Stimulus bridge controller under `app/javascript/controllers/railsfast/native` The token controller is intentionally tiny because the POST originates inside the authenticated WebView: ```ruby class NotificationTokensController < ApplicationController before_action :authenticate_user! PLATFORMS = { "ios" => "apple", "apple" => "apple", "android" => "google", "google" => "google" }.freeze def create platform = PLATFORMS[params.require(:platform).to_s.downcase] head :unprocessable_entity and return if platform.blank? device = ApplicationPushDevice.find_or_initialize_by(token: params.require(:token)) device.owner = current_user device.platform = platform device.name = params[:name].presence || "Mobile device" device.save! head :created end def destroy current_user.application_push_devices.where(token: params.require(:token)).destroy_all head :no_content end end ``` Server-side delivery credentials are platform-specific: - APNs needs an Apple Developer account, an APNs `.p8` key, key ID, team ID, and the app bundle ID as the APNs topic. - FCM needs a Firebase project, the Firebase project ID, and a service-account JSON private key for FCM v1 server auth. Render the native bridge only when the native app advertises `native-push`. If Android FCM server credentials are missing, either do not render the bridge for Android or reject Android token registration so no `google` devices are stored until delivery can actually succeed. ## iOS `railsfast-ios` includes the native side: - `NativePushComponent` answers the `register` bridge event. - `PushRegistrationService` requests an APNs token and caches it for repeated WebView bridge reconnects. - `PushNotificationRouter` routes `path` / `url` payloads after a notification tap. - `AppDelegate` implements APNs registration callbacks and `UNUserNotificationCenterDelegate`. - `SceneDelegate` consumes notification responses on cold launch and while the app is already running. - `RailsFast.entitlements` declares `aps-environment` through `RAILSFAST_APS_ENVIRONMENT`. - `project.yml` declares `UIBackgroundModes: remote-notification`. Before a downstream iOS app ships push: 1. Register the explicit App ID for the production bundle identifier. 2. Enable Push Notifications on that App ID. 3. Keep Associated Domains enabled if notification taps route to Universal Link URLs. 4. Set `RAILSFAST_APS_ENVIRONMENT = development` for Debug and `production` for Release. 5. Configure Rails APNs credentials and set the APNs topic to the bundle identifier. 6. Regenerate the project with `xcodegen generate`. 7. Test on a real device. Simulator behavior is not enough for final APNs verification. TestFlight and App Store builds use production APNs tokens. Xcode-installed development builds use development APNs tokens unless you explicitly configure the server to talk to APNs development for that environment. ## Android Android push uses Firebase Cloud Messaging. Play Console distribution and Firebase push are separate Google products; a Play account does not replace Firebase. `railsfast-android` includes the native side: - `NativePushComponent` answers the web `native-push` bridge `register` event. - `NativePushPermission` requests Android 13+ notification permission and resumes the pending token request after the system dialog returns. - `RailsFastFirebaseMessagingService` receives FCM messages, builds foreground notifications, and logs token refreshes. - `PushNotificationRouter` routes `path` / `url` payloads into the app or out to the system browser. - The Gradle build adds Firebase Messaging through the Firebase BoM and applies the Google Services plugin only when a local `google-services.json` is present. Before a downstream Android app ships push: 1. Create a Firebase project. 2. Add the Android app using the exact package/application ID. 3. Download `google-services.json`. 4. Put it in `app/google-services.json`, or in a build-type/flavor-specific location if the app uses separate Firebase projects per environment. 5. Add Firebase Messaging through the Firebase BoM. 6. Apply the Google Services Gradle plugin for builds that have a `google-services.json`. 7. Add a `FirebaseMessagingService` in the manifest for `com.google.firebase.MESSAGING_EVENT`. 8. Request `android.permission.POST_NOTIFICATIONS` at runtime on Android 13+ before showing notifications. 9. Configure Rails FCM credentials before allowing Android tokens to register. The Android `native-push` component should get the current FCM token, reply to the WebView bridge with `{token, platform: "android"}`, and let the WebView POST to Rails. The native service should handle foreground data messages by creating a notification with a `PendingIntent` back into the app. For background notification messages, FCM can display the notification through the system tray; the tap launches the app and data payload keys are delivered in the launcher intent extras. ## Verification Use this checklist before a release: - Rails logs show successful token registration for the signed-in user. - The stored device platform is `apple` for iOS and `google` for Android. - A notification with `path: "/notifications"` opens the app to `/notifications`. - An internal absolute app URL routes in-app. - An external absolute URL opens in the system browser. - iOS foreground notifications are presented with alert, sound, and badge. - Android foreground data messages produce a notification channel notification. - Deleting/signing out removes the current token when the shell supports sign-out cleanup. - Invalid/dead tokens self-clean through the push delivery layer or are explicitly removed on provider token errors. ## References - Apple, Registering your app with APNs: https://developer.apple.com/documentation/usernotifications/registering-your-app-with-apns - Apple, `UNUserNotificationCenterDelegate`: https://developer.apple.com/documentation/usernotifications/unusernotificationcenterdelegate - Firebase, Get started with Firebase Cloud Messaging in Android apps: https://firebase.google.com/docs/cloud-messaging/android/get-started - Firebase, Receive messages in Android apps: https://firebase.google.com/docs/cloud-messaging/android/receive-messages - Google, Google Services Gradle Plugin: https://developers.google.com/android/guides/google-services-plugin - Android Developers, Notification runtime permission: https://developer.android.com/develop/ui/compose/notifications/notification-permission - Rails `action_push_native`: https://github.com/rails/action_push_native - Noticed: https://github.com/excid3/noticed --- ## RailsFast Native - Safe areas & fixed docks Native shells run your Rails HTML **edge-to-edge** — the WebView paints under the system bars (Android's navigation bar / status bar, iOS's home indicator / notch). Anything pinned to the bottom (a "slide to start", a submit dock, an "Iniciar sesión" pill, an active-ride banner) has to reserve exactly the right amount of space to clear whatever system UI sits beneath it — no more, no less, on every device. RailsFast handles this with **one CSS contract** — a four-sided `--safe-t` / `--safe-r` / `--safe-b` / `--safe-l` and a `--dock-add` mode toggle — fed by a tiny native shell bridge on Android. Bottom (and a little top) is where the pain first shows up, so most of this page is about that; the horizontal pair is the Android 15 edge-to-edge follow-up covered further down. This page explains the contract, why `env(safe-area-inset-*)` alone isn't enough on Android, how to build a dock that's correct everywhere, and the edge-to-edge realities (four-sided insets + the unavoidable Play Console warning) you'll hit on `targetSdk` 35+. > [!TIP] > If you just want to ship a dock: wrap your screen in the `railsfast/native/dock_screen` component (or reach for a `.native-*` utility in your own markup) and you get all of this for free. The rest of this page is the *why*. ## The one rule Clear every device inset through a **named `.native-*` utility** — never inline the `max()`/`calc()` arithmetic in a view, and never hand-roll `env(safe-area-inset-*)`. One utility per job: | Surface | Utility | |---|---| | fixed **dock** — a button that must float above the opaque Android bar | `.native-dock-safe` | | **scroll-body** bottom — the last row clears the bar, sitting flush | `.native-safe-b` | | **top** clearance under the status bar / notch | `.native-safe-t` | | **horizontal** edge-pinned chrome (landscape cutout / waterfall) | `.native-safe-x` | Each utility is one line of CSS in `application.css`; a view just adds the class (plus a `--*-base` var when the gutter isn't the `1rem` default). Under the hood the dock utility is: ``` padding-bottom: max(calc(var(--safe-b) + var(--dock-add) * BASE), BASE) ``` `BASE` is the breathing room you want when there's no system inset (e.g. `1.125rem`), set per-call via the utility's `--*-base` var. And **never** pull a dock down with a negative `bottom` — Android's nav bar is opaque and tappable, so anything under it is buried and un-tappable. A `native_safe_area_contract_test` **fails CI** if a view re-inlines a padding safe-area formula, so "the arithmetic lives in one place" is enforced, not just advised — see [The contract test](#the-contract-test-guardrail) below. ## Why not just use `env(safe-area-inset-bottom)`? On **iOS** you can — WKWebView's `env()` is context-correct, and RailsFast leaves it as the default. On **Android** `env()` is wrong in two ways, so the native shell overrides the vars: > [!WARNING] > **1. Version floor.** Chromium only started mapping the *navigation bar* into a WebView's `env(safe-area-inset-bottom)` in **M136** (2025-04, fullscreen) / **M144** (2026-01, all WebViews). Below that it returns **0** — so a full-bleed CTA would fall back to only `BASE` and get buried under the opaque ~48dp bar. The Android shell only *nudges* users to update their WebView (a dismissible dialog), so 120–139 is a real, un-updatable tail. (`108`, sometimes cited, is *display-cutout/notch* support — not the nav bar.) > > **2. Spurious inset on embedded WebViews.** Even on a current WebView, `env()` reports the whole **window** nav-bar inset (~48px) *even when the WebView is laid out above the native tab bar* (`activity_main.xml` constrains each tab's `FragmentContainerView` `bottom_toTopOf @id/bottom_nav`; the tab bar itself carries the nav-bar inset). The nav bar doesn't touch that WebView, so ~48px of dock padding is spurious → a visible gap above the tab bar. Chromium does **not** bounds-check `env()` to the WebView's on-screen rect (measured: `env=48` on both a 795px tab-embedded WebView and a 923px full-bleed one). ## How the vars get their values `app/assets/tailwind/application.css` defines the defaults: ```css :root { --safe-t: env(safe-area-inset-top, 0px); --safe-r: env(safe-area-inset-right, 0px); --safe-b: env(safe-area-inset-bottom, 0px); --safe-l: env(safe-area-inset-left, 0px); --dock-add: 0; } ``` All four sides are defined even though bottom/top do the heavy lifting — see [The four-sided contract](#android-15-edge-to-edge-the-four-sided-contract) below for why `--safe-l` / `--safe-r` exist and which surfaces read them. - **iOS / desktop / mobile web** use these as-is — `env()` is correct, and `--dock-add: 0` collapses the dock formula to `max(var(--safe-b), BASE)`. - **Android** — `railsfast-android`'s `SafeAreaInsetBridge` computes the *real* overlap of each safe-drawing band (`systemBars() or displayCutout()`) with the WebView's window rectangle and sets all four `--safe-*` vars on `documentElement` (inline style beats the `:root` default), plus `--dock-add: 1`. Each side is `0` when a native bar (tab bar / toolbar) sits between the WebView and that system inset, and the full inset when the WebView reaches the screen edge — a numeric, version-independent replacement for the bounds-check Chromium doesn't do. The bridge installs from `WebFragment` / `FreezableWebBottomSheetFragment` on `onWebViewAttached`, re-applies on `onColdBootPageCompleted` / `onVisitCompleted` (a cold boot resets `documentElement`'s inline style), and on a passive layout listener (rotation, gesture↔3-button nav-mode switch, tab show/hide, keyboard `adjustResize`). It deliberately does **not** use `setOnApplyWindowInsetsListener` on the WebView — that would replace the WebView's own inset handling and break the `env()` fallback. ## Additive (Android) vs `max()` (iOS/web): what `--dock-add` is for The two platforms' bottom insets are physically different, so one arithmetic can't be identical: | Platform | Bottom inset | Desired dock position | `--dock-add` | Formula collapses to | |---|---|---|---|---| | Android 3-button | opaque, tappable ~48dp bar | `BASE` **above** the bar | `1` | `var(--safe-b) + BASE` | | Android gesture | thin pill | same (inset is tiny) | `1` | `var(--safe-b) + BASE` | | iOS | thin home-indicator pill | **at** the zone top | `0` | `max(var(--safe-b), BASE)` | | Web | browser inset / none | at the inset top | `0` | `max(var(--safe-b), BASE)` | On an opaque bar you *must* clear it and leave a margin (additive). On iOS's thin indicator, sitting a further `BASE` above the whole 34pt zone reads as a floating gap — so you sit at the zone top (`max`). ## Behaviour matrix (full-bleed vs tab-embedded) | Context | `--safe-b` | `--dock-add` | `padding-bottom` | Result | |---|---|---|---|---| | Android full-bleed (onboarding / welcome) | 48px | 1 | `48 + BASE` | dock `BASE` above the opaque bar | | Android tab-embedded (a pushed settings screen) | 0 | 1 | `BASE` | dock `BASE` above the tab bar | | iOS full-bleed | 34px | 0 | `max(34, BASE)` | dock at the home-indicator top | | iOS tab-embedded | 34px | 0 | `max(34, BASE)` | same, no floating gap | | Web | `env` | 0 | `max(env, BASE)` | at the inset top | ## Android 15 edge-to-edge: the four-sided contract Everything above is framed around the *bottom* (and a little *top*) because that's where the pain first shows up — a CTA buried under the nav bar. But the safe-area contract is **four-sided**: `--safe-t`, `--safe-r`, `--safe-b`, `--safe-l`. The horizontal pair is the edge-to-edge follow-up, and it exists because of a hard Android 15 reality. **Edge-to-edge is enforced for `targetSdk` 35+.** On Android 15+, an app targeting SDK 35 or higher is laid out edge-to-edge by default — the system bars go transparent and your content draws *under* them. There's no opt-out for targeted apps (Android 16 removed the last opt-out flag entirely). Every `Activity` must call `enableEdgeToEdge()` and then *consume the insets itself*. > [!IMPORTANT] > `enableEdgeToEdge()` is only the window-mode switch — it doesn't pad anything, it just says "you own the whole window now, including the parts under the bars." App-owned content still has to reserve space for the real insets. In RailsFast that's a two-part job: the native shell reads `WindowInsets`, and the web layer reserves space via the `--safe-*` vars. Neither half is optional on SDK 35+. **Why horizontal matters.** In portrait, left/right insets are `0` — a phone's status/nav bars are top/bottom — so `--safe-l` / `--safe-r` are a no-op and every `max(var(--safe-l), X)` collapses to `X` (byte-identical to a plain `X` padding). Horizontal insets go non-zero only with: - a **display cutout / notch in landscape** — the camera hole moves to a side edge when the device rotates, - a **waterfall display** — curved side edges the system reserves, - **3-button nav in landscape** — the bar can sit on a side. These are reachable whenever the app is **not orientation-locked**. RailsFast's Android manifest sets no `screenOrientation`, so a user *can* rotate an onboarding / auth / map screen into landscape and hit a side cutout — which is exactly why the contract went four-sided. (iOS ships portrait-only, so its left/right insets are physically `0`; see [iOS: nothing to do](#ios-nothing-to-do).) **On the native side, "safe drawing" is `systemBars() or displayCutout()`.** Android's own guidance is that critical content should clear the *union* of the system bars and the display cutout, not the system bars alone. The bridge computes per-view overlap against that union and injects all four `--safe-*` vars. Native full-bleed surfaces (the Maps camera padding, floating map controls, bottom-sheet content) apply the same `systemBars() or displayCutout()` insets on left/right too, so a landscape cutout never clips a map control or a floating back button. > [!WARNING] > Do **not** "simplify" the native inset source back to `systemBars()` only. That fixes portrait top/bottom but silently drops landscape / cutout / waterfall clearance — the exact gap that shipped in the first pass and had to be re-opened. **Consume horizontal insets only on edge-pinned chrome.** You do *not* need to sweep every `px-4` / `px-5` in the app. Content in a centered, constrained column (`mx-auto max-w-md`) already clears any phone cutout — in an ~800px landscape viewport the ~175px/side centering margin dwarfs a ~30–50px cutout, so its horizontal padding never bites. Only **fixed / full-bleed / edge-pinned** surfaces can touch a physical side edge, so those are the only ones that read `--safe-l` / `--safe-r`: - the edge-pinned app header (its `justify-between` bell/avatar row) - full-bleed hero / onboarding progress headers - the native auth entry screen and the onboarding submit dock - fixed action docks and the active-ride banner - `absolute left` back overlays on the trip-map header and live-ride screen - the full-screen chat attachment overlay + its `absolute right` close button Each edge-pinned surface reaches for the horizontal (and top) utilities — `max()` against the design gutter under the hood, so portrait is byte-identical: ```erb
``` An `absolute`-positioned overlay is the one exception: it pins an *offset* (`top:` / `left:` / `right:`), which a padding utility can't express, so it keeps reading the vars inline — `left-[max(var(--safe-l),1rem)]`, `right-[max(var(--safe-r),0.75rem)]` — instead of a bare `left-4` / `right-3`. The contract test's guard is padding-only, so these positional reads are exempt. ## The Play Console "deprecated window APIs" warning Once you ship edge-to-edge, Google Play Console will likely flag the release for **deprecated window APIs**: - `android.view.Window.setStatusBarColor` - `android.view.Window.setNavigationBarColor` - `LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES` It's tempting to chase this to zero warnings. **Don't.** Here's the honest picture, verified by bytecode disassembly: 1. **Deprecated ≠ removed.** These APIs still exist and still function; on Android 15 they only *no-op for gesture-nav bar color*. A RailsFast app draws behind the bars and pads with insets — it never relied on bar colors — so runtime behavior is unaffected. The warning is a Play Console **advisory, not a rejection**; apps ship with it in production. 2. **It's almost always upstream library bytecode, not your code.** The start points Play names are AndroidX (AppCompat, WorkManager), Material Components' `EdgeToEdgeUtils`, and the Google Places SDK — not app source. A RailsFast app's own code and theme call *none* of these (it uses `enableEdgeToEdge()` + `WindowInsetsCompat`). A `javap` disassembly of **Material 1.14.0** (current at time of writing) shows `EdgeToEdgeUtils` *still* calls `Window.setStatusBarColor` / `setNavigationBarColor` via `invokevirtual`. Material is the app's design system (`Theme.Material3.DayNight.NoActionBar`) and can't be dropped. See the open upstream issue: [material-components-android#4732](https://github.com/material-components/material-components-android/issues/4732). 3. **It is therefore effectively unresolvable — and non-blocking.** Short of the entire AndroidX/Material ecosystem removing these calls upstream, you can't reach zero. Static scanners (Play, `dexdump`) report the *presence* of a symbol in bytecode without reachability or runtime-guard analysis, so they keep flagging it. > [!TIP] > Keep the edge-to-edge-sensitive dependency stack reasonably current and move on. Review these **as a group** before a Play upload (not one-by-one), because they're precisely the surface Play names: > > | Dependency | Role | > |---|---| > | `androidx.activity` | owns `enableEdgeToEdge()` | > | `androidx.core` | owns `WindowInsetsCompat.Type` (`systemBars`, `displayCutout`) | > | `androidx.appcompat` | flagged compat bytecode | > | `com.google.android.material` | `EdgeToEdgeUtils` — keeps the warning alive | > | `androidx.work` | flagged startup/compat bytecode | > | `com.google.android.libraries.places` | widget bytecode (if bundled) | > > When pinning, verify the real constraint: e.g. `androidx.core` 1.19.0 needs `compileSdk` 36.1 — *not* a mythical "SDK 37" — so it's fine on a 36.1 app. Don't invent version blockers. ## Downstream note: the Google Places SDK Worth documenting because it bites edge-to-edge audits specifically. If your app bundles the **Places Android SDK** but only uses the **programmatic client** (`FindAutocompletePredictionsRequest` / `fetchPlace`) — never Google's bundled autocomplete/photo *widget* UI — the SDK still merges its widget `Activity` entries into your manifest. Those unused activities expand the edge-to-edge audit surface: Play can name an SDK activity as a deprecated-API start point even though your product flow never launches it. Strip them from the merged manifest with `tools:node="remove"`: ```xml ``` > [!NOTE] > This shrinks the manifest-reachable surface (a legit win for the separate *edge-to-edge-attribution* warning), but it does **not** clear the *deprecated-API* warning — Material keeps that alive regardless (see above). The only way to fully drop the Places widget bytecode is to remove the SDK entirely and proxy autocomplete/details through a server-side Places web-service API — a much larger API-key/billing/latency/abuse-control tradeoff, not worth it just to silence a static warning. If you ever adopt the widget UI, delete these removals and audit that UI as a first-party edge-to-edge screen. ## Checklist: shipping on `targetSdk` 35+ 1. **Call `enableEdgeToEdge()` in every `Activity`.** It's the window-mode switch; without it you aren't honoring the SDK 35+ contract. 2. **Consume the four `--safe-*` vars through the `.native-*` utilities** on fixed / full-bleed / edge-pinned web chrome — bottom+top always, left+right on the edge-pinned surfaces above. Never hand-roll `env(safe-area-inset-*)` or inline the `max()`/`calc()` formula in a view; a contract test fails CI if you do. 3. **Add the `SafeAreaInsetBridge`** so Android injects real per-view `WindowInsets` overlap into those vars — `env()` alone is unreliable on WebView below ~M136/M144 and over-reports on tab-embedded WebViews. 4. **Use `systemBars() or displayCutout()`** as the native inset source (not `systemBars()` alone), and apply it to native full-bleed surfaces (map padding, floating controls, sheet content) too. 5. **Keep the edge-to-edge-sensitive deps current** (activity / core / appcompat / material / work / places), reviewed as a group before each Play upload. 6. **Accept the residual deprecated-API warning.** It's upstream library bytecode (Material et al.), non-blocking, and effectively unresolvable. Don't chase zero, and don't drop the Places SDK expecting it to help — it won't. ## Components Two components cover the common cases — both read the safe-area SSOT for you, so you never hand-roll `env()`. ### `railsfast/native/dock_screen` — whole-screen dock The screen IS a dock: a non-scrolling, viewport-filling flex column (fixed header / scrollable body / bottom dock). The dock is a **flex child**, never `position: fixed`, so it can't rubber-band with iOS WKWebView overscroll. Use it whenever the primary action must stay pinned while a body scrolls. ```erb <%= render layout: "components/railsfast/native/dock_screen", locals: { title: "Checkout", dock: (capture { render "checkout/pay_button" }) } do %> ...scrollable content... <% end %> ``` ### `railsfast/native/fixed_dock` — fixed bar over content A `position: fixed` bar pinned to the viewport bottom — a persistent CTA or a floating banner over normal, scrollable page content. Safe-area-aware via `.native-dock-safe`. ```erb <%= render "components/railsfast/native/fixed_dock" do %> <%= button_to "Continue", checkout_path, class: "btn btn-primary w-full" %> <% end %> <%# transparent floating banner, larger gap, non-blocking %> <%= render "components/railsfast/native/fixed_dock", base: "0.75rem", surface: false, class: "pointer-events-none" do %> ...banner... <% end %> ``` Locals: `base` (breathing room above the inset, default `1rem`), `surface` (white bar + hairline top border, default `true`), `class` (extra classes). > [!WARNING] > A `position: fixed` dock rubber-bands with iOS WKWebView overscroll on a **document-scrolling** page (it visibly "jumps" near the bottom). Use `fixed_dock` for overlays/banners and for pages whose document doesn't meaningfully scroll; when the whole screen should be a pinned dock, use `dock_screen` instead. ### The `.native-*` utilities — the raw building blocks The components apply these for you, but you can use them directly for bespoke chrome. Four utilities, one per job — each is the single home of its arithmetic, and each takes a per-call base var (default `1rem`) for the gutter when there's no inset: | Utility | Pads | Use for | Base var | |---|---|---|---| | `.native-dock-safe` | bottom, **additive** over the inset on Android | a fixed button dock that must float above the opaque nav bar | `--dock-base` | | `.native-safe-b` | bottom, **flush** at the inset | a scroll body's last row (content resting at the bar is fine) | `--safe-b-base` | | `.native-safe-t` | top | a sticky/fixed header under the status bar / notch | `--safe-t-base` | | `.native-safe-x` | left **and** right | edge-pinned chrome that can meet a landscape cutout / waterfall edge | `--safe-x-base` | ```erb <%# bespoke fixed dock — additive over the Android bar %>
...
<%# top-sticky header on a full-bleed screen %>
...
<%# clear all four sides (e.g. a full-screen overlay) %>
...
``` The dock is additive (`.native-dock-safe`); a scroll body sits flush (`.native-safe-b`) — pick by whether an opaque bar would bury the content or merely sit under it. Only **absolute-positioned overlays** stay inline: a padding utility can't express `top:` / `left:` / `right:`, so those keep `top-[max(var(--safe-t),1rem)]` / `left-[max(var(--safe-l),1rem)]` and are exempt from the contract test's padding guard. ### The contract test (guardrail) `test/integration/native_safe_area_contract_test.rb` is what keeps the SSOT from rotting as the app grows. It: - asserts all four `--safe-*` vars + `--dock-add` exist, and that each `.native-*` utility carries its exact formula; - **fails CI if any view re-inlines a padding safe-area read** (`p*-[…var(--safe-*)…]`) — the whole point of the utilities is that the arithmetic lives once, so a re-inlined `pb-[max(var(--safe-b),…)]` is a regression, not a style nit; - exempts **positional** (`top-`/`left-`/`right-`) reads, which legitimately stay inline — asserted against the guard regex directly so a future cutout-aware overlay never trips it; - carries a per-file allowlist for deliberate `hotwire-native:`-prefixed overrides a plain utility can't express (e.g. an app-header whose top pad is additive on native but `max()` on web). It's **empty in the base template** — add your own as you need them, and a companion test prunes dead entries. This is what lets "never inline `env()` or the formula" be a rule the build enforces, not a convention reviewers have to remember. ## iOS: nothing to do There is no iOS twin of `SafeAreaInsetBridge`, and the four-sided contract needs **no** iOS change. WKWebView's `env(safe-area-inset-*)` is context-correct (via `viewport-fit=cover` in every native layout, plus a `.never`/`.automatic` content-inset policy) — it reflects the WebView's actual safe area, including the tab bar and any `additionalSafeAreaInsets` the shell mirrors for the keyboard — so all four `--safe-*` vars keep their `env()` default and `--dock-add: 0` gives the `max()` behaviour. iOS also ships **portrait-only**, so `--safe-l` / `--safe-r` are physically `0` there regardless. See [[native/4-ios|Native iOS]] for the `stable_fixed_dock` / `contentInsetAdjustmentBehavior` viewport policy that keeps `bottom: 0` landing at the device edge. ## References - [Understand window insets in WebView](https://developer.android.com/develop/ui/views/layout/webapps/understand-window-insets) - [Android 15 edge-to-edge enforcement + deprecated window APIs](https://developer.android.com/about/versions/15/behavior-changes-15#edge-to-edge) · [Android 16 (opt-out removed)](https://developer.android.com/about/versions/16/behavior-changes-16) - [Android edge-to-edge guide](https://developer.android.com/develop/ui/views/layout/edge-to-edge) · [Display cutout insets](https://developer.android.com/develop/ui/views/layout/edge-to-edge#display-cutout-insets) - [Material still calls deprecated window APIs (upstream, open)](https://github.com/material-components/material-components-android/issues/4732) - [Places programmatic autocomplete](https://developers.google.com/maps/documentation/places/android-sdk/place-autocomplete#get_place_predictions_programmatically) · [Manifest merge rule markers](https://developer.android.com/build/manage-manifests#merge_rule_markers) - [capacitor-community/safe-area](https://github.com/capacitor-community/safe-area) — documents Chromium `<140` returning `0` - [Chrome edge-to-edge](https://developer.chrome.com/docs/css-ui/edge-to-edge) · [CSS `env()`](https://developer.mozilla.org/en-US/docs/Web/CSS/env) --- ## RailsFast Native - Dark mode RailsFast ships system-following dark mode across web, Android, and iOS, plus a light/dark/system override that keeps web content and native chrome coherent. The preference has one source of truth—the Rails page’s `localStorage.theme`—and each native shell keeps only a cold-start mirror. > [!TIP] > It is already wired in all three starter repositories. The dashboard header and Settings page use the same resolver, and both native shells register the `theme` bridge component. ## The model | Surface | Source | How it changes | |---|---|---| | Rails content | `window.__rfTheme` + `data-theme` | Authored Tailwind dark ramp; never pixel-inverted | | Android View chrome | XML Material3 roles | `AppCompatDelegate` DayNight mode + in-place restyle | | Android Compose screens | The same XML roles | `RailsFastTheme`, which adapts those roles to Compose | | iOS UIKit/SwiftUI chrome | Semantic colors | Window trait via `overrideUserInterfaceStyle` | | Fixed-palette native “island” | Its own screen | Explicit fixed-light Activity theme / VC override | The direction is deliberately one-way: ```text localStorage.theme (web SSOT) │ ├── data-theme + Tailwind content │ └── Hotwire "theme" bridge ├── Android SharedPreferences → AppCompat └── iOS UserDefaults → UIWindow trait ``` Native storage exists only because native views render before a Rails document connects. Every themed page reasserts the web value, so a stale native mirror self-heals. ## Web SSOT (`railsfast-base`) The core files are: | File | Responsibility | |---|---| | `layouts/html_head/_color_scheme.html.erb` | Anti-FOUC resolver, storage, cross-document sync, preference event | | `assets/tailwind/application.css` | Neutral + semantic dark ramps, physical-overlay exceptions, and `.keep-light` | | `railsfast/ui/theme_toggle_controller.js` | Header/Settings UI state only | | `railsfast/native/theme_controller.js` | Web → native bridge sender | | `native/_theme_bridge.html.erb` | Hidden page-scoped bridge element | `window.__rfTheme` owns five operations: - `resolve()` returns the rendered `light`/`dark` scheme. - `apply()` writes it to ``. - `set(preference)` is the only writer of `localStorage.theme`. - `current()` returns `light`, `dark`, or `system`. - `snapshot()` atomically returns the preference, resolved scheme, and durability source (`storage`, `memory`, or `unavailable`) for the native bridge. `system` is represented by an absent storage key. RailsFast follows `prefers-color-scheme` on web and native by default. If your public web design must default to light while native still follows the device, `_color_scheme` documents the one-line `isNative` variant. ### Scope and anti-FOUC A page darkens only when its layout renders `_color_scheme` and its body has `data-app-themed`. The `dashboard`, `devise`, and `native_handoff` layouts opt in; the public `application` layout stays authored-light. `apply()` resets an unthemed body to light after Turbo navigation so the `` attribute cannot leak from app UI into marketing UI. The resolver stays inline in `` because Stimulus connects after paint. Turbo compares head elements with CSP nonces removed, retains the identical script across app-to-app visits, and the resolver binds each listener once. ### Multiple controls and multiple documents Every call to `set()` dispatches `railsfast:theme-preference`. All theme-toggle controller instances listen to it, so changing the header control also updates a Settings control already present in the same document. Hotwire’s tab shells eagerly own separate same-origin documents. A browser `storage` event fires in the *other* documents when one writes `localStorage`; the resolver listens for it, reapplies the page, updates its controls, and reasserts the same preference to that document’s native bridge. This avoids a stale preloaded tab becoming visible later. If storage throws (private browsing, full storage, or a locked-down WebView), the resolver keeps a session-only in-memory preference. Failed writes, same-value writes, and sandboxed recipients cannot rely on `storage`, so every explicit choice also crosses a same-origin `BroadcastChannel`. A snapshot signature suppresses duplicate channel + Storage delivery. A document whose initial read failed requests a peer snapshot; a durable response outranks any later memory-only response, while a memory-only recovery snapshot remains session-only and never mutates Storage merely because it answered first. When a *fresh user choice* arrives from a sandboxed peer, a writable receiver promotes it and broadcasts a durable acknowledgement. That acknowledgement is required even for a same-value write (which emits no `storage` event), so the sender and native cold-start mirror learn that the SSOT is now persistent. [Broadcast Channel API](https://developer.mozilla.org/en-US/docs/Web/API/Broadcast_Channel_API). The durability source is part of the bridge message. Native applies a `memory` choice live without persisting it, so it cannot turn a rejected web write into an invented cold-start preference. `unavailable` means even the initial read failed; native clears a potentially stale mirror and follows the system. Missing source defaults to durable storage for compatibility with an older web bundle. ### Bridge startup and restore behavior The theme controller sends on connect without checking `this.enabled`. That is intentional: `@hotwired/hotwire-native-bridge` 1.2.2 queues `Bridge.send()` while no native adapter exists and flushes the queue from `setAdapter()`. Guarding on `enabled` would bypass the public queue and lose the initial value on eagerly-created tabs. Unsupported clients do not instantiate the controller because `BridgeComponent.shouldLoad` checks the component list in the native User-Agent. Hotwire’s `BridgeComponent.restore()` invokes `connect()` without a Stimulus disconnect. The theme controller therefore creates its preference handler once in `initialize`; repeated restores reassert once without accumulating event listeners. The shared bridge partial can also appear on an intentionally unthemed public page. When `window.__rfTheme` is absent, the controller does nothing—it must never invent `system` and erase an explicit native mirror merely because the user visited a light-only page. Exact sources: [bridge pending queue](https://github.com/hotwired/hotwire-native-bridge/blob/v1.2.2/src/bridge.js#L30-L38), [component gating and lifecycle](https://github.com/hotwired/hotwire-native-bridge/blob/v1.2.2/src/bridge_component.js). ## Tailwind ramps, contrast, and fixed web islands Tailwind v4 utilities read theme variables, so one scoped neutral ramp changes the whole app without editing every view: ```css html[data-theme="dark"] { --color-white: #1c1c1e; /* card */ --color-black: #ffffff; /* alpha ink */ --color-gray-50: #000000; /* page base */ /* gray-100 … gray-950 */ } ``` The default elevation ladder matches iOS: base `#000`, elevated `#1c1c1e`, raised `#2c2c2e`. The alternate neutral families (`slate`, `zinc`, `neutral`, and `stone`) alias that same gray elevation/ink ladder while dark mode is active. RailsFast views use several of them, and leaving a stock `text-slate-700` unchanged would make it dark-on-dark. One ladder also prevents subtly different “black” page surfaces from creating seams against native chrome. Chromatic utilities need role-aware handling too. A stock `bg-red-50 text-red-800` alert is light-surface/dark-ink; leaving those values unchanged in dark mode can produce less than 2:1 contrast. RailsFast swaps the low surface/border rungs (`50`/`100`/`200`) with their dark counterparts and every high ink rung (`700`/`800`/`900`/`950`) with its light counterpart for all standard Tailwind chromatic families, from red through rose. Do not omit `950`: real warning and admin headings commonly pair `bg-*-50` with `text-*-950`, which otherwise collapses to essentially 1:1. Because low rungs can also be intentionally pale text on a dark badge, exact `text-*-50`/`100`/`200` utilities retain their authored ink at the `color` property; exact `text-*-500`/`600` utilities lift to `*-400`. High-rung action backgrounds—including `bg-*-950`—are likewise corrected at the `background-color` property, not by overriding an inherited theme variable; otherwise a `hover:bg-red-700` token can darken a nested `text-red-700` before hover. The literal values come from [Tailwind’s canonical theme](https://github.com/tailwindlabs/tailwindcss/blob/v4.3.1/packages/tailwindcss/theme.css). `--color-white` is intentionally adaptive: it is the elevated card surface in dark mode and lets neutral `bg-gray-900 text-white` actions invert coherently. White ink on an unchanged dark chromatic fill is different, so the stylesheet restores literal `#fff` only when the element has an actual base fill whose canonical Tailwind shade measures at least 4.5:1 against white. It deliberately does not infer foreground from a future `hover:bg-*` token before hover. Bright amber/green-style actions should use explicit stable dark ink and a brighter hover; for example `keep-light bg-amber-600 text-gray-950 hover:bg-amber-500`. Do not assume a class named `text-white` is readable—the runtime system tests measure the rendered foreground over every translucent ancestor and enforce a 4.5:1 minimum. Likewise, `--color-black` becomes adaptive light ink for hairlines, but a modal veil is physical black. Full-screen `bg-black/40`, `/50`, and `/85` overlays, `backdrop:bg-black/80`, and black image-gradient stops have narrowly scoped literal-black corrections. Add a matching correction and contrast contract when introducing another full-screen opacity. Use `.keep-light` for a logo, brand/accent chip, image overlay, or other subtree whose authored palette must not flip. It restores every neutral and chromatic token that the dark SSOT remaps, including the property-level `text-*-500`/`600` lift; it is a real palette boundary rather than a white/gray special case. Keep it on the smallest leaf subtree: pinning an app page or modal container also freezes every adaptive child token. ## Android (`railsfast-android`) ### Preference application `ThemeComponent` receives `{ preference, source, reason }`, normalizes the atomic snapshot in `ThemePreference`, persists only durable values, and maps it to: | Preference | AppCompat mode | |---|---| | `dark` | `MODE_NIGHT_YES` | | `light` | `MODE_NIGHT_NO` | | `system` / invalid | `MODE_NIGHT_FOLLOW_SYSTEM` | `RailsFastApplication` applies the mirror before any Activity inflates. The static system splash may appear before app code and can only follow its resource/OS theme; the guarantee begins with the first process-owned Activity frame. The mirror file (`railsfast_theme.xml`) and `app_webview/` are both excluded from Android backup. The WebView store is excluded to avoid migrating cookies/session state, so restoring only the native mirror would create a stale first frame. ### Seamless switch: no Activity or WebView recreation Both Hotwire host activities declare `android:configChanges="uiMode"`. When `setDefaultNightMode` changes mode, AppCompat 1.7.1 takes its resource-update branch rather than `ActivityCompat.recreate()`. The Activity, Hotwire navigators, WebView document, scroll position, and Turbo state survive. There is an important AppCompat nuance: `updateResourcesConfiguration()` reapplies the theme only when its private `mThemeResId` is nonzero. A manifest-supplied theme commonly leaves it zero. `NativeChromeTheme` calls `ResourcesCompat.ThemeCompat.rebase()` before reading attributes—direct `Theme.rebase()` on API 29+ and AndroidX’s compatibility shim on API 23–28—then rewrites colors baked into already-inflated views. Do not reapply `ActivityInfo.theme` as an API-28 fallback: a launch Activity’s manifest resource can be its temporary Splash theme, already replaced by `installSplashScreen()` with `postSplashScreenTheme`. - status/navigation bar icon contrast; - bottom-navigation background, selected indicator, icons, labels, and pressed/focus/hover state layers; - top app bar, title, back arrow, overflow, and menu icons; - an open Material bottom sheet. Resolve from the Activity context, not a `MaterialToolbar`/dialog theme-overlay context: an overlay context can retain its pre-change configuration. Also use `MaterialToolbar.setNavigationIconTint`; assigning a tinted drawable alone is overwritten when Material reapplies its own navigation tint. The bottom-navigation ripple is an inflation-time `ColorStateList`, just like its icon and label tints. Re-loading Material's XML selector through `AppCompatResources` after an in-place switch is not sufficient: its resource cache can return the pre-switch list even after `ThemeCompat.rebase()`. `NativeChromeTheme` therefore rebuilds Material 1.14.0's exact state table from the freshly resolved `colorOnSurface` (10% pressed/focus, 8% hover). Keep that table and its API-35 device regression aligned with [`m3_navigation_bar_ripple_color_selector.xml`](https://github.com/material-components/material-components-android/blob/1.14.0/lib/java/com/google/android/material/bottomnavigation/res/color/m3_navigation_bar_ripple_color_selector.xml) when upgrading Material. Exact source: [AppCompat 1.7.1 sources JAR](https://repo1.maven.org/maven2/androidx/appcompat/appcompat/1.7.1/appcompat-1.7.1-sources.jar), methods `AppCompatDelegateImpl.updateAppConfiguration` and `updateResourcesConfiguration`. See also [`configChanges`](https://developer.android.com/guide/topics/manifest/activity-element#config), [`ThemeCompat.rebase()`](https://developer.android.com/reference/androidx/core/content/res/ResourcesCompat.ThemeCompat#rebase(android.content.res.Resources.Theme)), and [SplashScreen’s `postSplashScreenTheme`](https://developer.android.com/reference/kotlin/androidx/core/splashscreen/SplashScreen.Companion#(android.app.Activity).installSplashScreen()). ### WebView and open sheets On modern targetSdk 33+ WebView, `prefers-color-scheme` comes from the Activity theme’s `isLightTheme`; do not enable algorithmic darkening. Hotwire Native 1.2.8 also initializes a legacy FORCE_DARK media-query strategy once in `HotwireWebView` for older providers. Because the seamless path preserves that WebView, `refreshThemeSignal` reapplies Hotwire’s authored-CSS-only strategy on API 32 and below before calling `window.__rfTheme.apply()`. It rebases the Activity theme first, then derives dark/light from the effective public `android:isLightTheme` attribute rather than Hotwire’s raw `Configuration.uiMode` check; otherwise either the stale pre-swap theme or a fixed-light Activity on an OS-dark device can report the wrong legacy scheme. An open `FreezableWebBottomSheetFragment` survives the same configuration change. It re-tints the native `design_bottom_sheet`, refreshes its WebView signal, and reapplies the resolver. Theme bridge messages from dialogs are therefore applied immediately; deferring them would leave the sheet’s web and native halves in different modes. ### Compose is not automatically adaptive A root Compose `MaterialTheme {}` does not inherit the Android View theme; Material3’s composition-local fallback is `lightColorScheme()`. `RailsFastTheme` observes `LocalConfiguration` for recomposition, chooses its light/dark baseline from the Activity’s effective `android:isLightTheme` (not raw uiMode, which can remain night inside a fixed-light Activity), resolves the XML semantic roles, and supplies a full scheme including `surfaceContainer*`. Native Compose screens must use it. Omitting the container roles is how lavender/tonal cards reappear even after View chrome is neutral. Exact current source: [Material3 1.3.2 sources JAR](https://dl.google.com/dl/android/maven2/androidx/compose/material3/material3-android/1.3.2/material3-android-1.3.2-sources.jar), `MaterialTheme.kt` / `ColorScheme.kt`. ### Material roles | XML role | Main consumer | Light | Dark | |---|---|---|---| | `colorSurface` | app/nav bar, page backing | white | `#000` | | `colorOnSurface` | primary native ink | gray-900 | near-white | | `colorSurfaceContainerLow` | sheets/cards | white | `#1c1c1e` | | `colorSurfaceContainerHigh` | raised dialogs/cards | white | `#2c2c2e` | | `colorSecondaryContainer` | selected nav indicator | neutral | `#2c2c2e` | | `colorOnSurfaceVariant` | secondary/unselected ink | gray-500 | system gray | Material3’s baseline selected-nav pair is lavender (`#4A4458` / `#E8DEF8` in dark). Keep the explicit neutral `colorSecondaryContainer` / `colorOnSecondaryContainer` override unless lavender is your brand. ## iOS (`railsfast-ios`) `ThemeComponent` receives the same `{ preference, source, reason }` snapshot and `ThemePreferenceStore` maps it to a window override. As on Android, a `memory` value changes the live trait but is not persisted: | Preference | `UIUserInterfaceStyle` | |---|---| | `dark` | `.dark` | | `light` | `.light` | | `system` / invalid | `.unspecified` | `SceneDelegate` applies the persisted mirror after constructing the UIWindow and before making it visible. Live changes apply to every connected scene window. UIKit semantic colors, SwiftUI semantic colors, navigation/tab bar appearances, and WKWebView traits then update in place. Hotwire’s `BridgeComponent` is already `@MainActor` in 1.3.0-beta, so the endpoint applies synchronously; wrapping each message in an unstructured Task can reorder rapid selections. The mirror uses `UserDefaults`, which Apple includes in device backups. It is still not an authority: every page connect reasserts the WebKit value and repairs drift after restore. [Apple `UserDefaults`](https://developer.apple.com/documentation/foundation/userdefaults). Do not add `UIUserInterfaceStyle` to Info.plist—that would pin the whole app and break `system`. The static launch screen cannot read the web override because app code has not run; its named color follows the OS appearance. The runtime StartupLoadingView and every subsequent frame inherit the window override. Exact source: [Hotwire Native iOS `BridgeComponent`](https://github.com/hotwired/hotwire-native-ios/blob/1.3.0-beta/Source/Bridge/BridgeComponent.swift#L29-L52), [UIKit override API](https://developer.apple.com/documentation/uikit/uiview/3238086-overrideuserinterfacestyle). ## Fixed-light native islands If a fully-native screen uses fixed brand colors (for example, a yellow map UI), pin that screen rather than changing the app-wide preference: - Android: assign an Activity theme whose parent is `Theme.Material3.Light.NoActionBar`; do not inherit a base redefined in `values-night`. Use fixed light tokens and dark system-bar icons. Never call process-wide `setDefaultNightMode` to pin one screen. - iOS: set `overrideUserInterfaceStyle = .light` on the UIViewController. If it presents an independent controller/navigation stack containing a WKWebView, pin that presented controller too; do not rely on presentation inheritance. ## Verification checklist Test each matrix row from a cold launch and from an already-open page: | Device/OS | Web choice | Expected | |---|---|---| | light | system | all light | | dark | system | all dark | | light | dark | web + native dark | | dark | light | web + native light | Also verify: - no Activity `onCreate`, navigation, reload, scroll reset, or white flash on Android toggle; - back/overflow icons, bottom-navigation pressed/focus/hover state layers, and an already-open bottom sheet recolor; - every eagerly-preloaded tab shows the new choice when selected; - header and Settings controls agree; - `native:restore` produces one reassertion, not duplicate change messages; - blocked storage keeps every preloaded document coherent through `BroadcastChannel`, marks the bridge value `memory`, and does not overwrite the durable native mirror; - a writable peer promotes a fresh memory-only choice, broadcasts a durable `source: storage` acknowledgement even for a same-value write, and a durable recovery response outranks later memory-only snapshots; - an unavailable initial Storage read clears a stale native mirror to `system`; - public/light-only layouts do not reset native preference; - soft error/warning/success/info/role surfaces, pale low-rung ink, and standalone/deepest-`950` semantic text, literal-white chromatic actions, adaptive neutral actions, hover states, and nested semantic ink all meet 4.5:1 rendered contrast; - modal/dialog backdrops remain physical black (including translucent alpha) rather than becoming adaptive white veils; - Android API 28–32 and 33+ WebView paths; - iOS launch → runtime boundary, nav/tab bars, sheets, and downstream fixed-light islands; - Android backups exclude both `app_webview/` and the native theme mirror. ## Do not enable algorithmic darkening `WebSettingsCompat.setAlgorithmicDarkeningAllowed(true)` does not enable authored dark mode. It pixel-inverts pages that do not support dark, diverges from iOS, and mangles intentionally light public/legal content. WebView already reports its host trait through `prefers-color-scheme`; let Rails CSS paint the page. Contract: [`WebSettings#setAlgorithmicDarkeningAllowed`](https://developer.android.com/reference/android/webkit/WebSettings#setAlgorithmicDarkeningAllowed(boolean)). ## References - [Tailwind dark mode](https://tailwindcss.com/docs/dark-mode#with-system-theme-support) · [theme variables](https://tailwindcss.com/docs/theme) - [`prefers-color-scheme`](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-color-scheme) · [`color-scheme` meta](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/meta/name/color-scheme) - [Hotwire Native bridge components](https://native.hotwired.dev/reference/bridge-components) - [Android dark theme/WebView](https://developer.android.com/develop/ui/views/theming/darktheme#web) · [Material color roles](https://m3.material.io/styles/color/roles) - [Apple HIG color](https://developer.apple.com/design/human-interface-guidelines/color) · [`UIUserInterfaceStyle`](https://developer.apple.com/documentation/uikit/uiuserinterfacestyle) See also: [[native/2-railsfast-base|Native server]] · [[native/3-android|Android]] · [[native/4-ios|iOS]] · [[native/8-safe-area-and-docks|Safe areas & fixed docks]] --- ## RailsFast Native - Web Forms & The Keyboard Your RailsFast app renders its forms as web pages inside a native WebView. That is what makes RailsFast fast — but the WebView is a browser engine, and browser engines have opinions about the on-screen keyboard that surprise people the first time. The short version, verified on both shells: > [!IMPORTANT] > Focusing a field from code — the HTML `autofocus` attribute, Turbo's post-visit autofocus, or a JS `element.focus()` — **focuses the field on every platform, but does NOT raise the software keyboard on iOS or Android.** The on-screen keyboard only comes up on a real user gesture (a tap). This is deliberate browser-engine behavior, not a RailsFast bug, and it is the same on both native shells. So a chat composer, a search box, or any "land here ready to type" screen will open with the **caret in the field** but the **keyboard down** until the user taps. On desktop web there is no soft keyboard, so `autofocus` is purely a focus nicety there. ## Why — it's a browser-engine user-activation policy | Platform | `autofocus` focuses the field? | Soft keyboard rises on open? | |---|---|---| | **iOS** (WKWebView) | Yes | **No** — tap to raise | | **Android** (WebView / Chromium) | Yes | **No** — tap to raise | | **Desktop web** | Yes | n/a (no soft keyboard) | - **Android WebView (Chromium)** gates programmatic focus behind the [`focus-without-user-activation`](https://groups.google.com/a/chromium.org/g/blink-dev/c/GLmp1IrJ4Ig) Permissions Policy. `element.focus()`, `autofocus`, `window.focus()` and friends do not summon the keyboard without user activation. The documented native lever is `webView.requestFocus()` + [`InputMethodManager.showSoftInput()`](https://developer.android.com/develop/ui/views/touch-and-input/keyboard-input/visibility). ([WebView has behaved this way for years.](https://issuetracker.google.com/issues/36915710)) - **iOS WKWebView** applies the same user-gesture requirement. WKWebView has no public API to change it. You can prove it to yourself on Android: load a page with `