Rails with React
Rails and React work together when you need a rich, highly interactive frontend. Rails handles the backend — database, authentication, business logic, and API endpoints. React handles the frontend — dynamic UI components, client-side state, and complex user interactions. This combination gives you the best of both worlds: Rails productivity on the server and React flexibility in the browser.
When to Use React with Rails
Use Hotwire (no React) when: - Standard CRUD pages with occasional interactivity - Comment sections, live search, inline editing - You want to ship fast with minimal JavaScript Use React with Rails when: - Complex UI with lots of client-side state - Real-time dashboards with many updating parts - Drag-and-drop, rich text editors, complex wizards - Your team already knows React well
Two Approaches
Approach 1: Rails API + React SPA Rails app: --api mode, serves only JSON React app: Separate project (Create React App / Vite) Communication: React fetches from Rails JSON API Approach 2: React Inside Rails (Hybrid) Rails serves HTML pages as normal React components mounted in specific page sections Useful when most pages are standard but some need rich UIs
Approach 1: Rails API Backend
Create an API-only Rails app:
rails new myapp-api --api --database=postgresql cd myapp-api
Build your API controllers:
app/controllers/api/v1/articles_controller.rb
module Api
module V1
class ArticlesController < ApplicationController
before_action :authenticate_request
def index
articles = Article.published.includes(:user).order(created_at: :desc)
render json: articles.map { |a| serialize_article(a) }
end
def show
article = Article.find(params[:id])
render json: serialize_article(article)
end
def create
article = current_user.articles.build(article_params)
if article.save
render json: serialize_article(article), status: :created
else
render json: { errors: article.errors.full_messages }, status: :unprocessable_entity
end
end
private
def article_params
params.require(:article).permit(:title, :body, :published)
end
def serialize_article(article)
{
id: article.id,
title: article.title,
body: article.body,
published: article.published?,
author: article.user.name,
created_at: article.created_at.iso8601
}
end
end
end
end
CORS — Allow React to Call Your API
By default, browsers block requests from one origin (React app) to another origin (Rails API). Enable CORS to allow it:
Gemfile: gem "rack-cors" bundle install
config/initializers/cors.rb
Rails.application.config.middleware.insert_before 0, Rack::Cors do
allow do
origins "http://localhost:3001", # React dev server
"https://myapp.com" # production React app
resource "/api/*",
headers: :any,
methods: [:get, :post, :put, :patch, :delete, :options],
credentials: true
end
end
Token Authentication for the API
rails generate migration AddApiTokenToUsers api_token:string
rails db:migrate
class User < ApplicationRecord
before_create :generate_api_token
private
def generate_api_token
self.api_token = SecureRandom.hex(32)
end
end
app/controllers/application_controller.rb
class ApplicationController < ActionController::API
def authenticate_request
token = request.headers["Authorization"]&.split(" ")&.last
@current_user = User.find_by(api_token: token)
render json: { error: "Unauthorized" }, status: :unauthorized unless @current_user
end
def current_user
@current_user
end
end
The React Frontend
Create a React app with Vite (faster than Create React App):
npm create vite@latest myapp-frontend -- --template react cd myapp-frontend npm install npm install axios react-router-dom
Set the API base URL:
src/api.js
import axios from "axios"
const api = axios.create({
baseURL: "http://localhost:3000/api/v1",
headers: {
"Content-Type": "application/json"
}
})
// Attach token from localStorage to every request
api.interceptors.request.use((config) => {
const token = localStorage.getItem("auth_token")
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
export default api
React Component Fetching from Rails API
src/components/ArticleList.jsx
import { useState, useEffect } from "react"
import api from "../api"
export default function ArticleList() {
const [articles, setArticles] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
api.get("/articles")
.then(res => { setArticles(res.data); setLoading(false) })
.catch(err => { setError("Failed to load articles"); setLoading(false) })
}, [])
if (loading) return <p>Loading articles...</p>
if (error) return <p>{error}</p>
return (
<div>
<h1>Articles</h1>
{articles.map(article => (
<div key={article.id}>
<h2>{article.title}</h2>
<p>By {article.author} — {article.created_at}</p>
<p>{article.body}</p>
</div>
))}
</div>
)
}
Login Component
src/components/Login.jsx
import { useState } from "react"
import api from "../api"
export default function Login({ onLogin }) {
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const [error, setError] = useState(null)
const handleSubmit = async (e) => {
e.preventDefault()
try {
const res = await api.post("/sessions", { email, password })
localStorage.setItem("auth_token", res.data.token)
onLogin(res.data.user)
} catch {
setError("Invalid email or password.")
}
}
return (
<form onSubmit={handleSubmit}>
<h1>Log In</h1>
{error && <p>{error}</p>}
<input type="email" value={email} onChange={e => setEmail(e.target.value)} placeholder="Email" />
<input type="password" value={password} onChange={e => setPassword(e.target.value)} placeholder="Password" />
<button type="submit">Log In</button>
</form>
)
}
Sessions Controller in Rails
app/controllers/api/v1/sessions_controller.rb
module Api
module V1
class SessionsController < ApplicationController
skip_before_action :authenticate_request
def create
user = User.find_by(email: params[:email])
if user&.valid_password?(params[:password])
render json: {
token: user.api_token,
user: { id: user.id, name: user.name, email: user.email }
}
else
render json: { error: "Invalid credentials." }, status: :unauthorized
end
end
def destroy
current_user&.regenerate_api_token
render json: { message: "Logged out." }
end
end
end
end
Approach 2: React Components Inside Rails Views
Use the react-rails gem to mount React components directly in ERB views:
gem "react-rails" bundle install rails generate react:install
Generate a component:
rails generate react:component ArticleEditor title:string body:string
Mount it in any view:
<%= react_component("ArticleEditor", {
title: @article.title,
body: @article.body,
saveUrl: article_path(@article)
}) %>
This renders the React component server-side for the initial HTML, then hydrates it in the browser for full interactivity.
Full Stack Architecture Diagram
Browser
|
+-- React App (port 3001)
| Components, state, routing
| Axios HTTP calls
| |
| | GET/POST /api/v1/articles
| | Authorization: Bearer token
| v
+-- Rails API (port 3000)
API controllers
ActiveRecord models
PostgreSQL database
Deployment Options
| Service | Deploy Rails API | Deploy React App |
|---|---|---|
| Heroku + Netlify | git push heroku main | netlify deploy |
| Heroku + Vercel | Heroku CLI | vercel deploy |
| Linux server | Nginx + Puma | Nginx serves built React files |
| Render.com | Web Service (Rails) | Static Site (React build) |
Pairing Rails with React gives you a scalable, maintainable full-stack architecture. Rails provides a robust, secure, tested backend in a fraction of the time it would take from scratch. React delivers the interactive, responsive frontend experience modern users expect. Together they cover every layer of a professional web application.
