Skip to content
ExponaBLOG
RUBY ON RAILS · DAY 079 / 100

Rails and React: Ruby on Rails best practices explained

Combine Rails with a React frontend.

Expona Admin7 min read

After spending years shuttling data between monolithic backends and frontend clients, most teams eventually ask the same question: how do we build modern UIs without abandoning the productivity of Rails? Rails and React: Ruby on Rails best practices explained covers the architectural patterns that let you combine Rails with a React frontend efficiently, securely, and without unnecessary complexity. Whether you're adding a React component layer to an existing Rails app or building a new one from scratch, the decisions you make around API design, authentication, and deployment will shape your team's velocity for years.

Rails API with a separate React app

The cleanest separation of concerns for mid-to-large projects is to run Rails as a dedicated API backend and React as an entirely separate frontend application. This approach shines when your UI needs differ from your data model, or when you want to serve mobile apps, third-party integrations, or multiple frontend frameworks from the same Rails backend.

In this pattern, Rails serves JSON over REST or GraphQL endpoints. Your React app lives in its own repository, its own build process, and its own deployment pipeline. This means you can version them independently, deploy frontend changes without touching the backend, and even run different teams on each side.

A typical Rails API setup looks like this:

ruby
class Api::V1::OrdersController < ApplicationController
  def index
    @orders = current_user.orders.order(created_at: :desc)
    render json: @orders, each_serializer: OrderSerializer
  end

  def create
    @order = Order.new(order_params)
    @order.user = current_user

    if @order.save
      render json: @order, serializer: OrderSerializer, status: :created
    else
      render json: { errors: @order.errors.full_messages }, status: :unprocessable_entity
    end
  end

  private

  def order_params
    params.require(:order).permit(:total_amount, :delivery_address, line_items_attributes: [:product_id, :quantity])
  end
end

On the React side, you'd use a client library like Axios or the Fetch API to communicate with these endpoints. The key advantage: your React app doesn't care about Rails-specific conventions. You can use whatever build tooling, CSS framework, or component library you prefer.

The trade-off is operational overhead. You now manage two separate applications, two separate deployments, and CORS configuration. For teams with solid DevOps practices, this is worth it. For a small team just getting started, it might feel like overkill.

Or Inertia for Rails

If you want the ergonomics of a Rails monolith but with a modern React component layer, Inertia is the middle ground. Inertia lets you write React components that feel native to Rails, with server-side data flowing directly into client-side props, without building a separate JSON API.

The concept is elegant: Rails renders views, but instead of returning HTML, it returns JSON that describes which React component to render and what props to pass. The browser loads a single JavaScript bundle once, and from then on, navigation is handled client-side. It feels like a traditional Rails app, but your UI is 100% React.

Setting up Inertia in Rails looks like this:

ruby
# Gemfile
gem 'inertia_rails'

# config/initializers/inertia.rb
InertiaRails.configure do |config|
  config.layout = 'app'
end

# app/controllers/orders_controller.rb
class OrdersController < ApplicationController
  def index
    @orders = current_user.orders.order(created_at: :desc)
    render inertia: 'Orders/Index', props: { orders: @orders }
  end

  def show
    @order = Order.find(params[:id])
    authorize_user!(@order)
    render inertia: 'Orders/Show', props: { order: @order }
  end
end

On the React side, you write components that receive these props:

ruby
// app/javascript/pages/Orders/Show.jsx
import React from 'react'
import OrderDetails from '../../components/OrderDetails'

export default function OrderShow({ order }) {
  return (
    <div className="order-page">
      <h1>Order #{order.id}</h1>
      <OrderDetails order={order} />
    </div>
  )
}

Inertia is ideal when you want to stay close to the Rails mental model while modernizing your views. You keep your Rails controllers as the primary router, your models as the source of truth, and you avoid the complexity of a separate frontend deployment. Many teams find this strikes the right balance between simplicity and modern UX.

Share auth with cookies

Authentication is where many Rails + React projects stumble. Whether you choose a separate API or Inertia, the way you handle sessions matters enormously for security and user experience.

The simplest and most secure approach is to stick with Rails' traditional cookie-based sessions. Rails handles session encryption and CSRF protection out of the box, and browsers manage cookies automatically. Your React component can read cookie-stored user data and act accordingly, without needing to manually manage tokens in JavaScript.

Configure Rails to allow same-domain requests:

ruby
# config/initializers/cors.rb
if Rails.env.development?
  Rails.application.config.middleware.insert_before 0, Rack::Cors do
    allow do
      origins 'localhost:3000', 'localhost:3001'
      resource '*', headers: :any, methods: [:get, :post, :put, :patch, :delete], credentials: true
    end
  end
end

# config/application.rb
config.session_store :cookie_store, key: '_myapp_session', same_site: :lax
config.middleware.use ActionDispatch::Cookies
config.middleware.use ActionDispatch::Session::CookieStore

When a user logs in, Rails sets a secure, encrypted cookie. Every request from the React app includes that cookie automatically. Rails' built-in session verification and CSRF token system work as designed. Your React components can check a user's session via a /api/current_user endpoint that returns null if not authenticated:

ruby
class Api::CurrentUserController < ApplicationController
  def show
    if current_user
      render json: current_user, serializer: UserSerializer
    else
      render json: nil, status: :unauthorized
    end
  end
end

This approach avoids the complexity of JWT token refresh logic, localStorage vulnerabilities, and XSS attack surface. It's battle-tested across countless production Rails applications and works seamlessly with both separate React apps and Inertia setups.

Deploy on one domain when possible

A common mistake is deploying your React frontend to app.example.com and your Rails backend to api.example.com. This creates CORS complexity, cookie scoping issues, and operational friction. When feasible, deploy both on the same domain.

If you're using Inertia, this is automatic—Rails serves both the API and the React bundle. If you're using a separate React app, consider reverse-proxying or static file hosting.

One clean pattern is to build your React app to a static bundle and have Rails serve it:

ruby
# config/routes.rb
namespace :api do
  namespace :v1 do
    resources :orders, only: [:index, :show, :create]
  end
end

# Serve React for all other routes (SPA catch-all)
root 'pages#app'

# config/application.rb
config.public_file_location = Rails.root.join('public', 'app')

After building your React app with npm run build, commit the bundle to your Rails app or build it as part of your deploy step. This way, a single Heroku dyno, AWS instance, or Docker container serves everything. Your cookies work across frontend and API, CSRF protection is simpler, and you have one deployment pipeline instead of two.

Common mistakes

Storing auth tokens in localStorage: Many developers new to React store JWT tokens in localStorage to "avoid cookies." This exposes tokens to XSS attacks. Cookies, especially with HttpOnly flag, are far safer because JavaScript cannot access them.

Not setting up CORS correctly: A misconfigured CORS setup leads to credential errors and lost cookies. Always include credentials: true in fetch requests and whitelist your origins explicitly in Rails.

Running separate dev servers without coordination: Having Rails on port 3000 and React on port 3001 is fine locally, but it's easy to forget that production needs both on the same domain. Test this assumption early.

Over-engineering the API: Trying to build a perfect REST API that works for web, mobile, and third parties often delays shipping. Start with what your React app needs. Refactor once you have a second client.

Ignoring Rails serializers: Don't let your React app depend on the exact shape of your Active Record objects. Use serializers to define an explicit contract between backend and frontend.

Key takeaways

  • Separate Rails API and React apps offer independence and scalability but require more operational setup; use this when you have a large team or multiple frontend clients.
  • Inertia provides the simplicity of a Rails monolith with modern React components; ideal for single-team projects that want fast iteration.
  • Cookie-based sessions with CSRF protection are more secure and simpler than JWT tokens for same-domain auth.
  • Deploy both Rails and React on the same domain to eliminate CORS headaches and keep cookies working correctly.
  • Start simple—choose an architecture that lets your team ship, not the one that looks most sophisticated on paper.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles