IMEOS
  1. Home
  2. Work
  3. Contact
  4. Blog

Im Obstgarten 7
8596 Scherzingen
Switzerland

+41 79 786 10 11
(CET/CEST, Mo-Fr, 09:00 - 18:00)
io@imeos.com

IMEOS on GitHub
React NativeExpoRailsHeroku

Deploy Expo Router React Native Web app on Heroku

Nov 19, 2022
7 minutes read
  • Have your Expo application inside the Rails directory structure
  • Use the right Heroku buildpacks
  • Set up a build script for your Expo project
  • Serve the SPA shell via Rails (and only the shell)
  • Set up caching correctly
  • Route everything to the SPA
  • Configure CORS to expose auth headers
  • Set up the development environment
  • Summary

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.

Related posts

  • RailsDeviseImplementing Passwordless Authentication with WebAuthn and Passkeys in Rails
    Nov 2, 2025
    7 minutes read
  • RailsAWSAmazon SES for transactional emails
    Mar 3, 2025
    15 minutes read
  • RailsMigrating from Redis to Solid Cache & Solid Cable in Rails
    Oct 18, 2024
    14 minutes read

  • Privacy
  • Imprint
IMEOS