Integrating Ruby on Rails with React Native using Expo
Primary Keyword: Rails React Native integration
Meta Description: Build full-stack mobile apps with Rails backend and Expo frontend. Master JWT authentication, API design, and data synchronization between Rails and React Native.
The Full-Stack Opportunity You're Probably Sleeping On
You know Rails. You can build a web API in your sleep. You've shipped production apps with Django, or Laravel, or Elixir. Now you want to build mobile.
Most teams choose: "Let's hire native iOS and Android developers" or "Let's use a cross-platform framework like React Native."
What fewer teams realize: you already have the skills for both. If you can build a Rails API, you can ship a production mobile app with Expo. The backend stays Rails. The frontend becomes React Native.
This is the full-stack opportunity: you own the entire product, from database to phone. No handoff to mobile specialists. No managing three codebases. One product, two frontends (web and mobile), one backend you understand completely.
Why this matters: We've shipped mobile products using Expo + Rails. The time to first release is 6–8 weeks, not 6 months. Deployment is straightforward. Bug fixes ship to both platforms simultaneously. Feature development is faster because the team understands the whole system.
By the end of this guide, you'll understand:
- How to structure a Rails API for mobile clients.
- Authentication strategies (JWT vs. sessions) and which to choose.
- How to handle data fetching and caching in Expo.
- How to manage errors and offline resilience.
- How to ship updates without app store delays.
Architecture: Rails API + Expo Frontend
The architecture is simple: Rails handles everything the database needs (auth, validation, business logic). Expo handles everything the phone needs (UI, offline support, native features).
┌──────────────────┐
│ Rails API │
│ - Database │
│ - Auth │
│ - Business │
│ logic │
└──────────────────┘
↕
HTTP/REST
↕
┌──────────────────┐
│ Expo Frontend │
│ - UI (React) │
│ - Caching │
│ - Offline │
│ - Native APIs │
└──────────────────┘
The contract between them is HTTP. Rails serves JSON. Expo consumes it.
Setting Up Rails for Mobile
Your Rails API should treat mobile clients like any other client. No special-casing. Just good API design.
1. Enable CORS for your frontend domain:
# config/initializers/cors.rb
Rails.application.config.middleware.insert_before 0, Rack::Cors do
allow do
origins 'localhost:8081', 'example.com' # Expo dev server, production domain
resource '*', headers: :any, methods: [:get, :post, :put, :patch, :delete]
end
end
Add the rack-cors gem if you haven't:
# Gemfile
gem 'rack-cors'
2. Structure API routes cleanly:
# config/routes.rb
Rails.application.routes.draw do
namespace :api do
namespace :v1 do
resources :users, only: [:show, :update]
resources :posts, only: [:index, :show, :create]
resources :comments, only: [:create, :destroy]
post '/auth/login', to: 'auth#login'
post '/auth/logout', to: 'auth#logout'
post '/auth/refresh', to: 'auth#refresh'
end
end
end
Versioning is important. When you need to change the API, v1 still works for older app versions.
3. Standardize error responses:
Every error should have the same shape:
# app/controllers/api/v1/base_controller.rb
class Api::V1::BaseController < ApplicationController
def render_error(message, status = :bad_request, details = {})
render json: {
error: {
message: message,
status: status,
details: details
}
}, status: status
end
end
# In your controller
class Api::V1::PostsController < Api::V1::BaseController
def create
post = Post.new(post_params)
if post.save
render json: post, status: :created
else
render_error('Failed to create post', :unprocessable_entity, post.errors)
end
end
end
Mobile clients need consistent error handling. Same shape, always.
Authentication: JWT vs. Sessions
This is the first decision: how do you authenticate between mobile and Rails?
Sessions (The Traditional Approach)
Rails loves sessions. They're secure, they're built-in, and they "just work" for web browsers.
For mobile, they're awkward.
How sessions work:
- User logs in with username/password.
- Rails stores the session in Redis or a database.
- Rails sends a Set-Cookie header.
- The browser stores the cookie and sends it on every request.
The problem for mobile:
Cookies are designed for browsers. Mobile HTTP clients can handle cookies, but:
- They're opaque to the app. You can't inspect or manage them easily.
- They're tied to domain and path. Mobile clients are more flexible.
- They're not ideal for offline or cross-device scenarios.
Session-based auth can work for mobile, but it's fighting the medium.
JWT (The Mobile-Friendly Approach)
JWT (JSON Web Tokens) is an alternative. Instead of storing state on the server, you sign a token that the client carries.
How JWT works:
- User logs in with username/password.
- Rails creates a JWT token (a signed JSON object) containing user info.
- Rails returns the token to the client.
- The client stores the token (in SecureStore, not localStorage).
- The client sends the token in the Authorization header on every request.
Advantages for mobile:
- The client fully controls the token. You can refresh it, clear it, store it securely.
- No cookies required. Works with mobile HTTP clients natively.
- Stateless. Rails doesn't need to store anything (though you might want a blacklist for logout).
- Works offline (sort of—your API still needs to be reachable for permission checks).
We recommend JWT for mobile + Rails. It fits the model better.
Implementing JWT in Rails + Expo
1. Set up JWT in Rails:
Add the gem:
# Gemfile
gem 'jwt'
Create an auth controller:
# app/controllers/api/v1/auth_controller.rb
class Api::V1::AuthController < Api::V1::BaseController
skip_before_action :authenticate_request!, only: [:login]
def login
user = User.find_by(email: auth_params[:email])
if user&.authenticate(auth_params[:password])
token = JwtToken.encode(user_id: user.id)
render json: {
user: UserSerializer.new(user),
token: token
}
else
render_error('Invalid credentials', :unauthorized)
end
end
def logout
# Optional: add token to blacklist if you want to revoke immediately
# For now, token expires naturally
render json: { message: 'Logged out' }
end
def refresh
current_user # This validates the token
token = JwtToken.encode(user_id: current_user.id)
render json: { token: token }
end
private
def auth_params
params.require(:auth).permit(:email, :password)
end
end
Create a JWT utility:
# lib/jwt_token.rb
class JwtToken
SECRET = Rails.application.secrets.secret_key_base
def self.encode(payload, exp = 7.days.from_now)
payload[:exp] = exp.to_i
JWT.encode(payload, SECRET, 'HS256')
end
def self.decode(token)
JWT.decode(token, SECRET, true, { algorithm: 'HS256' })[0]
rescue JWT::DecodeError, JWT::ExpiredSignature
nil
end
end
Add middleware to authenticate requests:
# app/controllers/api/v1/base_controller.rb
class Api::V1::BaseController < ApplicationController
before_action :authenticate_request!
protected
def authenticate_request!
token = request.headers['Authorization']&.split(' ')&.last
payload = JwtToken.decode(token)
if payload
@current_user = User.find(payload['user_id'])
else
render_error('Unauthorized', :unauthorized)
end
end
def current_user
@current_user
end
end
2. Set up JWT in Expo:
Store the token securely:
// lib/auth.ts
import * as SecureStore from 'expo-secure-store';
export async function saveToken(token: string) {
await SecureStore.setItemAsync('authToken', token);
}
export async function getToken() {
return await SecureStore.getItemAsync('authToken');
}
export async function clearToken() {
await SecureStore.deleteItemAsync('authToken');
}
Create an API client that injects the token:
// lib/api.ts
import axios from 'axios';
import { getToken } from './auth';
const API_URL = 'https://api.example.com/api/v1';
const apiClient = axios.create({
baseURL: API_URL,
});
// Inject token on every request
apiClient.interceptors.request.use(async (config) => {
const token = await getToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// Handle token expiration
apiClient.interceptors.response.use(
(response) => response,
async (error) => {
if (error.response?.status === 401) {
// Token expired, clear it and redirect to login
await clearToken();
// Trigger login screen (your app routing logic here)
}
return Promise.reject(error);
}
);
export default apiClient;
3. Login in Expo:
// screens/LoginScreen.tsx
import { useState } from 'react';
import { View, TextInput, Button, Text } from 'react-native';
import apiClient from '@/lib/api';
import { saveToken } from '@/lib/auth';
import { useRouter } from 'expo-router';
export default function LoginScreen() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const router = useRouter();
const handleLogin = async () => {
try {
const response = await apiClient.post('/auth/login', {
auth: { email, password },
});
await saveToken(response.data.token);
router.replace('(tabs)/home');
} catch (err) {
setError('Invalid email or password');
}
};
return (
<View style={{ padding: 20 }}>
<TextInput
placeholder="Email"
value={email}
onChangeText={setEmail}
keyboardType="email-address"
/>
<TextInput
placeholder="Password"
value={password}
onChangeText={setPassword}
secureTextEntry
/>
{error && <Text style={{ color: 'red' }}>{error}</Text>}
<Button title="Login" onPress={handleLogin} />
</View>
);
}
Refresh Tokens (Optional, But Recommended)
For extra security, use short-lived access tokens + refresh tokens.
In Rails:
def login
user = User.find_by(email: auth_params[:email])
if user&.authenticate(auth_params[:password])
access_token = JwtToken.encode(
{ user_id: user.id, type: 'access' },
1.hour.from_now
)
refresh_token = JwtToken.encode(
{ user_id: user.id, type: 'refresh' },
7.days.from_now
)
render json: {
user: UserSerializer.new(user),
accessToken: access_token,
refreshToken: refresh_token
}
else
render_error('Invalid credentials', :unauthorized)
end
end
def refresh
current_user
new_access_token = JwtToken.encode(
{ user_id: current_user.id, type: 'access' },
1.hour.from_now
)
render json: { accessToken: new_access_token }
end
In Expo:
Store both tokens:
export async function saveTokens(accessToken: string, refreshToken: string) {
await SecureStore.setItemAsync('accessToken', accessToken);
await SecureStore.setItemAsync('refreshToken', refreshToken);
}
export async function getAccessToken() {
return await SecureStore.getItemAsync('accessToken');
}
export async function getRefreshToken() {
return await SecureStore.getItemAsync('refreshToken');
}
Handle token refresh automatically:
apiClient.interceptors.response.use(
(response) => response,
async (error) => {
if (error.response?.status === 401) {
const refreshToken = await getRefreshToken();
try {
const response = await apiClient.post('/auth/refresh', {
token: refreshToken,
});
const newAccessToken = response.data.accessToken;
await SecureStore.setItemAsync('accessToken', newAccessToken);
// Retry original request with new token
error.config.headers.Authorization = `Bearer ${newAccessToken}`;
return apiClient(error.config);
} catch (refreshError) {
// Refresh failed, logout
await clearTokens();
}
}
return Promise.reject(error);
}
);
Data Fetching and Caching
Mobile networks are slow and unreliable. Your data fetching strategy matters.
Strategy: SWR or React Query
Both SWR (stale-while-revalidate) and React Query are excellent for mobile. They handle caching, refetching, and offline resilience.
We recommend SWR for its simplicity:
// hooks/useApi.ts
import useSWR from 'swr';
import apiClient from '@/lib/api';
export function useApi<T>(url: string | null) {
const { data, error, isLoading, mutate } = useSWR<T>(
url,
(url) => apiClient.get(url).then((res) => res.data),
{
revalidateOnFocus: false, // Don't refetch when app comes to foreground
dedupingInterval: 60000, // Cache for 1 minute
focusThrottleInterval: 300000, // Only refetch every 5 minutes
}
);
return {
data,
isLoading,
error: error?.message,
mutate, // Manual refresh
};
}
Use it in a screen:
// screens/PostsScreen.tsx
import { useApi } from '@/hooks/useApi';
import { FlatList, Text, View, ActivityIndicator } from 'react-native';
interface Post {
id: number;
title: string;
body: string;
}
export default function PostsScreen() {
const { data: posts, isLoading, error } = useApi<Post[]>('/posts');
if (isLoading) {
return <ActivityIndicator />;
}
if (error) {
return <Text>Error: {error}</Text>;
}
return (
<FlatList
data={posts}
renderItem={({ item }) => (
<View>
<Text style={{ fontSize: 16, fontWeight: 'bold' }}>{item.title}</Text>
<Text>{item.body}</Text>
</View>
)}
keyExtractor={(post) => post.id.toString()}
/>
);
}
Pagination and Infinite Lists
For large lists, implement pagination:
# app/controllers/api/v1/posts_controller.rb
def index
posts = Post.order(created_at: :desc)
.limit(20)
.offset((params[:page].to_i - 1) * 20)
render json: {
posts: posts,
page: params[:page].to_i,
total_pages: (Post.count / 20.0).ceil
}
end
In Expo, use SWR's infinite data feature:
// hooks/useInfinitePosts.ts
import useSWRInfinite from 'swr/infinite';
import apiClient from '@/lib/api';
export function useInfinitePosts() {
const getKey = (pageIndex: number) => {
return `/posts?page=${pageIndex + 1}`;
};
const { data, error, size, setSize } = useSWRInfinite(
getKey,
(url) => apiClient.get(url).then((res) => res.data),
{ revalidateFirstPage: false }
);
const posts = data?.flatMap((page) => page.posts) || [];
const isLoading = !data;
const hasMore = data?.[data.length - 1]?.page < data?.[data.length - 1]?.total_pages;
const loadMore = () => setSize(size + 1);
return { posts, isLoading, hasMore, error, loadMore };
}
Use it:
<FlatList
data={posts}
renderItem={({ item }) => <PostItem post={item} />}
keyExtractor={(post) => post.id.toString()}
onEndReached={() => hasMore && loadMore()}
onEndReachedThreshold={0.5}
/>
Error Handling and Resilience
Expect Network Failures
Assume your network will fail. Design for it.
In Rails, provide meaningful error responses:
# app/controllers/api/v1/base_controller.rb
rescue_from ActiveRecord::RecordNotFound do
render_error('Resource not found', :not_found)
end
rescue_from StandardError do |e|
Sentry.capture_exception(e) # Log to error tracking
render_error('Something went wrong', :internal_server_error)
end
In Expo, handle errors gracefully:
async function fetchWithRetry(url: string, maxRetries = 3) {
for (let i = 0; i < maxRetries; i++) {
try {
return await apiClient.get(url);
} catch (error) {
if (i === maxRetries - 1) throw error;
// Exponential backoff
const delay = Math.pow(2, i) * 1000;
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
}
Offline Support
Expo has NetInfo for detecting connectivity:
// hooks/useNetInfo.ts
import { useEffect, useState } from 'react';
import NetInfo from '@react-native-community/netinfo';
export function useNetInfo() {
const [isOnline, setIsOnline] = useState(true);
useEffect(() => {
const unsubscribe = NetInfo.addEventListener((state) => {
setIsOnline(state.isConnected ?? false);
});
return unsubscribe;
}, []);
return isOnline;
}
For data that needs to work offline, consider local caching:
// lib/offlineStorage.ts
import AsyncStorage from '@react-native-async-storage/async-storage';
export async function cacheData(key: string, data: any) {
await AsyncStorage.setItem(key, JSON.stringify(data));
}
export async function getCachedData(key: string) {
const data = await AsyncStorage.getItem(key);
return data ? JSON.parse(data) : null;
}
Then use it as a fallback:
export function useApi<T>(url: string | null) {
const { data, error, isLoading } = useSWR(
url,
async (url) => {
try {
const response = await apiClient.get(url);
await cacheData(url, response.data);
return response.data;
} catch (err) {
// Try cache as fallback
return await getCachedData(url);
}
}
);
return { data, isLoading, error };
}
Deployment and Updates
Deploying Rails
Rails deployment is standard. Use your favorite hosting (Heroku, AWS, DigitalOcean).
Key points:
- HTTPS only. Mobile clients won't send credentials over HTTP.
- Rate limiting. Mobile apps can be abused. Implement rate limiting.
- Versioning. Keep v1 endpoints stable. Add v2 for breaking changes.
Deploying Expo Apps
For development and testing, Expo Go is perfect. For production:
EAS Build:
eas build --platform all --profile production
This outputs binaries for TestFlight (iOS) and Google Play (Android).
OTA Updates (Over-The-Air):
For bug fixes and non-native changes, use Expo's update service:
eas update --branch production
This deploys JavaScript changes without going through the app store.
Real Example: A Task Management App
Let's tie it together. A simple task app: Rails backend, Expo frontend.
Rails API:
# app/models/task.rb
class Task < ApplicationRecord
belongs_to :user
validates :title, presence: true
end
# app/controllers/api/v1/tasks_controller.rb
class Api::V1::TasksController < Api::V1::BaseController
def index
tasks = current_user.tasks.order(created_at: :desc)
render json: tasks
end
def create
task = current_user.tasks.build(task_params)
if task.save
render json: task, status: :created
else
render_error('Failed to create task', :unprocessable_entity, task.errors)
end
end
def update
task = current_user.tasks.find(params[:id])
if task.update(task_params)
render json: task
else
render_error('Failed to update task', :unprocessable_entity, task.errors)
end
end
def destroy
task = current_user.tasks.find(params[:id])
task.destroy
render json: { message: 'Task deleted' }
end
private
def task_params
params.require(:task).permit(:title, :description, :completed)
end
end
Expo Frontend:
// screens/TasksScreen.tsx
import { useApi } from '@/hooks/useApi';
import { View, FlatList, Text, Button, TextInput } from 'react-native';
import { useState } from 'react';
import apiClient from '@/lib/api';
interface Task {
id: number;
title: string;
completed: boolean;
}
export default function TasksScreen() {
const { data: tasks, mutate } = useApi<Task[]>('/tasks');
const [newTitle, setNewTitle] = useState('');
const handleCreateTask = async () => {
try {
await apiClient.post('/tasks', {
task: { title: newTitle, completed: false },
});
setNewTitle('');
mutate(); // Refetch tasks
} catch (error) {
console.error('Failed to create task', error);
}
};
const handleToggleTask = async (task: Task) => {
try {
await apiClient.patch(`/tasks/${task.id}`, {
task: { completed: !task.completed },
});
mutate();
} catch (error) {
console.error('Failed to update task', error);
}
};
return (
<View style={{ padding: 20 }}>
<TextInput
placeholder="New task..."
value={newTitle}
onChangeText={setNewTitle}
/>
<Button title="Add Task" onPress={handleCreateTask} />
<FlatList
data={tasks}
renderItem={({ item }) => (
<View style={{ padding: 10, borderBottomWidth: 1 }}>
<Text
style={{
textDecorationLine: item.completed ? 'line-through' : 'none',
}}
>
{item.title}
</Text>
<Button
title={item.completed ? 'Undo' : 'Done'}
onPress={() => handleToggleTask(item)}
/>
</View>
)}
keyExtractor={(task) => task.id.toString()}
/>
</View>
);
}
Done. A full-stack task app in ~100 lines of code across both platforms.
Bringing It All Together
Building mobile with Rails + Expo is pragmatic. You leverage your existing skills, you own the full stack, and you ship faster.
The key points:
- Structure your Rails API well. CORS, versioning, consistent error responses.
- Use JWT for mobile authentication. It's stateless and client-friendly.
- Cache aggressively on the frontend. Mobile networks are slow.
- Handle errors and offline gracefully. Users will lose connection.
- Use EAS Build and Expo Updates. Deployment becomes simple.
Start with authentication (JWT). Then build a simple feature end-to-end (list items, create item). Then iterate.
Mobile development doesn't require learning Swift or Kotlin. If you know Rails and JavaScript, you already know most of what you need.
Have you built mobile with Rails? What patterns worked well? Share your experience on Twitter or LinkedIn—we'd love to hear how your team is shipping.