Expo Router “brings the best routing concepts from the web to native iOS and Android apps” and is a great solution when building React Native apps for web too.
Getting it to run on Heroku (together with a Ruby on Rails api-only application), is not really straightforward as of now though, so here is a small tutorial:
Have your Expo application inside the Rails directory structure
We chose app/frontend, but you can put it into other locations too. Tell the Rails autoloader to ignore it:
# config/application.rb
Rails.autoloaders.main.ignore(Rails.root.join('app/frontend'))
Use the right Heroku buildpacks
Make sure that heroku/ruby and heroku/nodejs buildpacks are used for your project.
Set up a build script for your Expo project
Your Expo project needs to be built into static files on each deploy. The root package.json drives this — Heroku runs yarn build automatically when it detects heroku-run-build-script:
{
"engines": { "node": "24.x", "yarn": "1.x" },
"scripts": {
"build": "cd app/frontend && yarn && yarn expo export --platform web --clear && cd ../../ && cp -R app/frontend/dist/* public/"
},
"heroku-run-build-script": true
}
The key detail: build output lands directly in the Rails public/ directory. This matters because Rails’ built-in ActionDispatch::Static middleware already knows how to serve files from public/ efficiently — so we don’t need a controller for static assets at all.
After a build, public/ looks roughly like this:
public/
├── _expo/
│ └── static/
│ ├── css/
│ │ └── <hash>.css ← content-hashed CSS bundles
│ └── js/
│ └── web/<hash>.js ← content-hashed JS bundles
├── assets/ ← fonts, images, etc.
├── index.html ← SPA shell
├── manifest.json ← PWA manifest
└── favicon.ico
Serve the SPA shell via Rails (and only the shell)
Let Rails serve all static assets automatically via ActionDispatch::Static (which it already does for anything in public/), and only use a controller for the one thing that needs dynamic handling — the SPA’s index.html:
class FrontendController < ApplicationController
include ActionController::MimeResponds
FRONTEND_INDEX = Rails.root.join('public/index.html').freeze
def index
respond_to do |format|
format.html do
if Rails.env.local?
# Inject the Rails port so the frontend knows where the API lives
port = defined?(Capybara) ? Capybara.server_port : 3100
html = FRONTEND_INDEX.read
placeholder = '<script id="port-placeholder"></script>'
unless html.include?(placeholder)
raise "[FrontendController] Port placeholder not found in #{FRONTEND_INDEX}."
end
html.gsub!(placeholder, "<script>window.APP_CONFIG = { port: #{port} };</script>")
render html: html.html_safe
else
# Stream the file directly — sets Last-Modified + ETag for conditional GETs
response.set_header('Cache-Control', 'no-cache')
send_file FRONTEND_INDEX, type: 'text/html; charset=utf-8', disposition: 'inline'
end
end
end
end
end
The index.html template needs a placeholder for this injection:
<head>
<!-- ... -->
<script id="port-placeholder"></script>
</head>
Set up caching correctly
Two-tier caching is important here. In production.rb:
# Cache static assets for 1 year (they have content hashes in their filenames)
config.public_file_server.headers = { 'cache-control' => "public, max-age=#{1.year.to_i}" }
The SPA shell (index.html) gets Cache-Control: no-cache from the controller (see above), so browsers always revalidate it. But the JS bundles and assets it references have hashed filenames — those get the 1-year cache. This means after a deploy, the browser fetches the new index.html (which references new bundle filenames), then caches those bundles for a year.
Route everything to the SPA
The catch-all route sends HTML navigation requests to the SPA shell. API calls, XHR requests, and static assets are unaffected:
# config/routes.rb
# Static assets (/_expo/*, /assets/*, /favicon.ico) are served by
# ActionDispatch::Static from public/ — no route needed.
# SPA catch-all: only HTML navigation requests get the shell
get '*path', to: 'frontend#index', constraints: lambda { |request|
!request.xhr? && request.format.html?
}
root 'frontend#index'
Configure CORS to expose auth headers
If you’re using token-based auth (e.g. devise_token_auth), you need CORS to expose the auth headers so the frontend can read them from responses:
# config/initializers/cors.rb
Rails.application.config.middleware.insert_before 0, Rack::Cors do
allow do
origins [
%r{\Ahttp://(?:localhost|127\.0\.0\.1):\d+\z},
'https://www.your-app.com',
%r{\Ahttps://.*\.ngrok-free\.app\z}
]
resource '*',
headers: :any,
methods: [:get, :post, :put, :patch, :delete, :options, :head],
expose: ['access-token', 'client', 'expiry', 'token-type', 'uid']
end
end
Without the expose list, browsers silently strip those headers from responses — auth will appear to work in dev (same-origin) but break in any cross-origin scenario.
Set up the development environment
In development, you need Expo’s Metro bundler, Rails, your Database, and background workers all running together. We use foreman (via bin/dev):
# Procfile.dev
expo: yarn develop --clear
postgres: pg_isready -p 5432 -h /tmp 2>/dev/null && tail -f /dev/null || postgres -D ...
web: PORT=3100 bin/rails server -b 0.0.0.0
worker: bundle exec good_job start
# Procfile (production — no Expo dev server needed)
web: bundle exec puma -C config/puma.rb
worker: bundle exec good_job start
Running bin/dev starts everything in parallel. In development, the Expo Metro bundler serves the frontend on port 8081 with hot-reloading. In production, the pre-built static files in public/ are served by Rails directly.
Summary
The key insight: don’t fight Rails, use it. Instead of writing controller actions to read and serve every static file, put the build output in public/ and let the framework’s static file serving do what it’s designed to do. The only custom controller you need is for the SPA shell — and even that is only because you need cache control headers and (in dev/test) port injection.